diff --git a/README.md b/README.md index 5be5973..6e3870f 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ agent-skills/ media/ aura-asset-images/ SKILL.md + build-youtube-companion-runbooks/ + SKILL.md unsplash-asset-images/ SKILL.md ui/ @@ -177,7 +179,7 @@ Conventions: ## Current library -This snapshot contains **123 skills** across five categories. +This snapshot contains **128 skills** across five categories. Use `find agent-skills -name SKILL.md | sort` for the source of truth. @@ -201,9 +203,10 @@ Operational skills for repeatable Codex work: - `write-like-meng-on-x` - calibrate concise X drafts against an authored voice corpus. - `x-bookmark-quote-posts` - turn recent X bookmarks into source-backed quote-post drafts. -### Media (2) +### Media (3) -Image sourcing skills: +Media production and image sourcing skills: +- `build-youtube-companion-runbooks` - package verified videos into titles, thumbnails, chapters, descriptions, social copy, publication gates, and upload checks. - `aura-asset-images` - use Aura Assets for stock-style design and marketing imagery. - `unsplash-asset-images` - pick high-quality Unsplash assets by use case, crop, and ratio. @@ -236,7 +239,7 @@ Player systems and feedback: Assets, performance, QA, and release: - `build-hybrid-game-assets`, `build-vesperfall-review-assets`, `optimize-threejs-games`, `test-playable-web-games`, `ship-web-games` -### Web design (81) +### Web design (85) Conversion and implementation: - `build-awwwards-quality-sites`, `landing-page`, `pricing-page`, `tailwindcss`, `animation-systems`, `webgl-landing-steering` diff --git a/agent-skills/codex/write-repo-readme/SKILL.md b/agent-skills/codex/write-repo-readme/SKILL.md new file mode 100644 index 0000000..3f925af --- /dev/null +++ b/agent-skills/codex/write-repo-readme/SKILL.md @@ -0,0 +1,113 @@ +--- +name: write-repo-readme +description: Write or rewrite a GitHub README that matches the owner's existing repos, with every claim checked against the code and every link verified before commit. Use when asked to create a README, fix an outdated one, make a repo presentable before sharing it, or set up a repo's About panel, description, topics, or preview image. +--- + +# Write a repo README + +A README is the landing page for a repo someone is about to share. It gets read once, quickly, by people deciding whether to click through. Two things sink it: claims that are wrong, and a voice that doesn't match the author's other work. + +Never write it from memory of the project. Read the code, measure the numbers, and check every link. + +## First, match the house style + +Before drafting anything, read two or three of the owner's existing READMEs. This is the step that matters most and the easiest one to skip. + +```bash +gh api "users//repos?per_page=100&sort=updated" --jq '.[] | select(.fork==false) | "\(.name)|\(.stargazers_count)|\(.description // "-")"' +gh api repos///readme --jq '.content|@base64d' +``` + +Pick their most recent substantial projects, not their most starred, since old repos reflect an older voice. Note their section names, whether they use bullets or prose, whether they open with a live link, whether they include a preview image, and how technical they let the middle get. Follow what you find rather than importing a generic template. + +## Structure + +Adapt to the house style, but this order works for a project people can look at: + +1. **Title** — the project's name. Not the domain it deploys to, not the repo slug if that differs from what the thing is called. +2. **One paragraph** saying what it is and what you can do with it. Concrete verbs. +3. **Live link**, prominent, if there is somewhere to see it running. +4. **Preview image** immediately after. +5. **Inspiration or credits**, when the project started from someone else's work. Put it near the top rather than burying it at the bottom. +6. **What is inside** — features as bullets, each naming a behavior rather than a technology. +7. **How it is made** — the two or three genuinely interesting mechanisms, explained well enough that a builder learns something. This is the section people remember. +8. **Run locally** — a copy-pasteable command. +9. **Deployment**, if not obvious. +10. **Other projects**, when asked for. +11. **Credits.** + +## Verify every claim + +Anything countable goes in only after you measure it: + +```bash +du -h index.html # file sizes +du -ch assets/*.woff2 # asset totals +rg -c "" # counts of items, routes, entries +``` + +Check feature claims against the source. If you write that it supports reduced motion, grep for `prefers-reduced-motion` first. If you write that it has no external requests, load the page and confirm zero cross-origin entries in `performance.getEntriesByType('resource')`. + +Describing other projects: take the words from the source, never invent them. A repo's own `description` field, or the site's own `og:description`: + +```bash +gh api repos// --jq '.description' +curl -sL -A "Mozilla/5.0 ..." | grep -oiE ']*og:description[^>]*>' +``` + +Writing your own marketing copy for someone's product puts words in their mouth. Quote what they already say about themselves. + +## Hunt for stale infrastructure claims + +READMEs rot at the deployment line first. A project that moved hosts still tells people about the old one, and that is the first sentence a visitor reads. Before finishing, check that the stated host, URL, branch and build command are still true, and delete config files belonging to hosts no longer in use. + +## Link-check before committing + +Every link, no exceptions. One bad URL in a README that just got tweeted is the whole cost of this step: + +```bash +grep -oE 'https://[^)]+' README.md | sort -u | while read u; do + printf " %-52s %s\n" "$u" "$(curl -s -o /dev/null -L -w '%{http_code}' --max-time 12 -A "Mozilla/5.0" "$u")" +done +``` + +Chase anything that isn't 200. Some hosts block bots and need a real user-agent before you conclude the link is dead. + +## Capture a preview image + +If the project is visual and running somewhere, a still belongs at the top. Screenshot the live site, not localhost, so the image proves the deployed thing works. + +Use the Codex in-app browser. Set a `1600 x 1000` viewport, load the deployed URL, +wait for fonts, media, and entrance motion to settle, stage the most representative +state, and save a browser-only screenshot. Reset the viewport when finished. + +**Stage the UI before the shutter.** A default screenshot catches the page at rest, which usually means the interesting feature is idle and invisible. Drive the page into the state that shows it working, then shoot. Call the project's own functions to do it rather than faking input. + +Convert down before committing, since a 2x PNG runs several megabytes: + +```bash +sips -Z 2400 -s format jpeg -s formatOptions 82 /tmp/preview.png --out assets/preview.jpg +``` + +Then look at the result before committing it. A preview that misrepresents the project is worse than none. + +## Set the About panel + +The README isn't the only thing people see. A repo with no description or homepage looks abandoned in search results and link unfurls: + +```bash +gh repo edit / \ + --description "" \ + --homepage "" \ + --add-topic --add-topic +``` + +## Don't decide these alone + +- **A license.** Adding one grants rights on the owner's behalf. Point out that it's missing, recommend a common choice, and let them pick. +- **Which projects to list.** Propose a short set and say what you left out and why. A full inventory reads as a résumé; three or four well-chosen links get clicked. +- **Claims about people, revenue, or usage** that you cannot verify from a source. Leave them out and say you did. + +## Report back + +Say what you verified rather than asserting it is correct: the numbers you measured, the link-check result, any stale claim you found and fixed. Flag the parts you wrote rather than sourced, since those are the ones the owner needs to read closely. diff --git a/agent-skills/game-development/develop-game-epithets/SKILL.md b/agent-skills/game-development/develop-game-epithets/SKILL.md new file mode 100644 index 0000000..e5ab494 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/SKILL.md @@ -0,0 +1,228 @@ +--- +name: develop-game-epithets +description: Develop and preliminarily screen original game protagonist epithets or role-titles from verified lore. Use for requests to name a player-character role like "Ashen One," generate dark-fantasy or genre-native hero titles, compare candidates, research game/storefront/entertainment/trademark/domain/handle conflicts, or prepare a naming recommendation before implementation. Treat the work as research, not legal clearance, and do not change production names or code unless the user separately approves a candidate and requests implementation. +--- + +# Develop Game Epithets + +Create a concise, speakable protagonist identity that belongs to the actual game world, then perform a current preliminary conflict screen and recommend one candidate. + +## Hold the implementation boundary + +- Treat naming as read-only research unless the user explicitly asks to implement an approved candidate. +- Do not rename files, symbols, UI copy, routes, assets, or production data during naming research. +- Preserve existing project changes and inspect the repository read-only. +- Label every trademark result as preliminary screening, not legal advice or clearance. + +## Follow the workflow + +### 1. Verify the lore + +Identify the authoritative project path and inspect the current source rather than relying on the brief or model memory. + +Use `rg` and targeted file reads to find: + +- player and character-select labels; +- world, level, location, and faction names; +- death, revival, checkpoint, and quest copy; +- inventory, relic, weapon, rarity, and item language; +- bosses, enemies, NPC address, and dialogue; +- title-card, objective, and ending copy. + +Record a compact evidence table with file links or source citations. Separate what the repository **establishes** from what it **does not establish**. + +If no authoritative project source is available, say so explicitly and switch +to **brief-only mode**. Treat only the facts the user supplied as canon, cite +them as user-provided brief evidence, and list every material unknown. Do not +invent a repository, imply that source verification occurred, or block a +creative round that can be completed honestly from the brief. + +### 2. Define the naming territory + +Extract: + +- protagonist function; +- verified wound, curse, return loop, obligation, or lack thereof; +- world metaphysics; +- central motifs and verbs; +- emotional tone; +- vocabulary to own; +- vocabulary to avoid because it belongs to enemies, other properties, or unsupported lore. + +Mark creative interpretations as interpretations. Never invent a curse, bloodline, scar, religion, rank, or chosen status to justify a name. + +### 3. Generate a broad candidate field + +Generate at least 30 genuinely distinct candidates across several structures: + +- compact compounds; +- agent nouns and offices; +- ritual or material identities; +- participial or state titles; +- two-word noun phrases; +- sentence-like mythic titles. + +Do not submit 30 suffix swaps. Honor every hard constraint literally across the entire field. Avoid saturated structures and terminology identified by the user. + +Favor names that: + +- remain concise and pronounceable; +- work with and without an article; +- can be addressed aloud; +- look strong in UI; +- coexist cleanly with the game title; +- communicate without fantasy word soup. + +Run a deterministic constraint audit before judging the field: + +```bash +printf '%s\n' "Candidate One" "Candidate Two" | + python3 /scripts/validate_candidate_field.py \ + --require RequiredTerm --ban BannedTerm --min-count 30 +``` + +Use the default substring matching when a required or banned element also +matters inside a compound. Use `--match word` only when the constraint applies +to a standalone word. Fix every reported violation before continuing. + +### 4. Run the first screen + +Evaluate every candidate for: + +- intended and accidental meaning; +- pronunciation and likely mishearing; +- person-title grammar versus weapon, attack, boss, location, or event grammar; +- genericness and search ownership; +- resemblance to famous game titles, characters, classes, factions, or generated-name vocabulary; +- unsupported lore claims; +- article and capitalization behavior; +- dialogue fit. + +Advance roughly 8–12 candidates. Reject obvious same-field conflicts immediately. + +### 5. Perform current conflict research + +Read [research-checklist.md](references/research-checklist.md) before researching finalists. + +Run the bundled exact-surface helper as a first pass: + +```bash +python3 /scripts/knockout_screen.py "Candidate One" "Candidate Two" --format markdown +``` + +Then browse current sources. Search: + +- exact, spaced, hyphenated, concatenated, plural, singular, and likely phonetic variants; +- games, characters, studios, novels, music, tabletop properties, fantasy products, and active brands; +- Steam, itch.io, Epic, GOG, PlayStation, Xbox, Nintendo, Apple App Store, and Google Play; +- USPTO, WIPO Global Brand Database, TMview/EUIPO, and UK IPO; +- exact `.com` plus one or two useful game-oriented domains through registry RDAP; +- major social/video handles where feasible. + +Use primary and official sources for trademark, registry, and storefront claims. Date-stamp every check and identify the jurisdiction. + +Respect the active environment's browser policy. If the task requires the Codex in-app browser, use it and do not substitute Chrome. + +For USPTO, search combined-mark exact forms and relevant close variants across all statuses. Include likely Nice classes 9, 41, and 28 without assuming other classes are irrelevant. + +If an official database is blocked, state that clearly. Do not imply a blocked check succeeded and do not treat an aggregator as equivalent to the official register. + +Reject a candidate with a meaningful current game, character, entertainment, or active-brand conflict even if no exact registered trademark appears. + +Classify the research state before deciding: + +- **Preliminary screen completed:** current broad-web and close-variant review, + major storefront review, official trademark checks for the relevant core + jurisdictions, and registry checks were all performed with documented + results. +- **Degraded screen:** automation, indexed snippets, or only some required + sources were available. Present a **research-incomplete creative + front-runner**, list every missing check, and do not invite production + adoption yet. +- **Blocked screen:** current conflict research could not be performed. Return + the creative work separately from the research blocker; do not call any + candidate screened. + +Use only careful conclusions: + +- “No obvious exact conflict found in this preliminary screen.” +- “No registry object was returned.” +- “The direct route returned HTTP 404.” + +Never say “available,” “cleared,” “safe,” or “guaranteed.” + +### 6. Score and decide + +Score the shortlist from 1–10 on: + +- lore fit; +- distinctiveness; +- spoken quality; +- visual/UI quality; +- searchability; +- trademark risk; +- storefront collision risk; +- domain and handle practicality. + +Treat the score as a decision aid, not arithmetic proof. A meaningful entertainment conflict is a knockout even when the total score is high. + +If no candidate survives responsibly, generate a new round instead of lowering the standard. + +### 7. Stress-test the finalists + +Test the winner in at least these contexts: + +1. character-select heading; +2. NPC address; +3. death or revival line; +4. inventory title; +5. quest text; +6. return announcement; +7. spoken boss taunt. + +Also test: + +- canonical capitalization; +- direct address without an article; +- narrative use with an article; +- pairing beside the game title; +- singular/plural ambiguity; +- likely voice pronunciation. + +Do not invent unsupported lore merely to make a test line work. + +### 8. Deliver one recommendation + +Use [report-template.md](references/report-template.md) for the full handoff. + +Lead with: + +- one clear recommendation, or a research-incomplete creative front-runner + when the required screen is degraded; +- a short lore-grounded rationale; +- the material conflict caveat. + +Then provide: + +- 2–4 strong fallbacks; +- a compact comparison table; +- winner dialogue/UI tests; +- a rejection ledger for attractive failures; +- direct sources and exact queries/databases checked; +- research date and jurisdictions; +- the preliminary-screening disclaimer; +- the required counsel/expanded-clearance next step. + +For a completed preliminary screen, end with the exact single recommended +name. For a degraded or blocked screen, end instead with +`Research-incomplete creative front-runner: ` so the final line cannot be +mistaken for an adoption recommendation. State that no production rename was +performed. + +## Keep the evidence honest + +- Distinguish exact matches, close matches, component crowding, and ordinary-language noise. +- Treat search-engine non-results as low visible saturation, not proof of non-use. +- Treat domain and handle state as volatile. +- Prefer a clear name with a disclosed caveat over an opaque coined word that merely looks unique. +- Preserve rejected candidates and reasons so later rounds do not repeat known failures. diff --git a/agent-skills/game-development/develop-game-epithets/agents/openai.yaml b/agent-skills/game-development/develop-game-epithets/agents/openai.yaml new file mode 100644 index 0000000..8bf32d5 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Develop Game Epithets" + short_description: "Create and screen lore-native protagonist titles" + default_prompt: "Use $develop-game-epithets to create and preliminarily screen a protagonist epithet for my game from its current lore." diff --git a/agent-skills/game-development/develop-game-epithets/references/report-template.md b/agent-skills/game-development/develop-game-epithets/references/report-template.md new file mode 100644 index 0000000..f33be72 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/references/report-template.md @@ -0,0 +1,126 @@ +# Naming report template + +**Research date:** `` + +**Scope:** protagonist epithet only; no production rename + +**Legal status:** preliminary naming and trademark screening, not legal advice + +## Contents + +- Recommendation and preliminary conflict result +- Verified naming territory +- Candidate field and researched shortlist +- Comparison and dialogue/UI tests +- Rejection ledger and research record +- Required next step and decision + +## Recommendation + +# `` + +**Pronunciation:** `` + +Explain in 2–4 sentences: + +- how the name grows from verified lore; +- why it works as a protagonist identity; +- why it is stronger than the fallbacks; +- the material conflict or clarity caveat. + +Usage: + +- “Rise, ``.” +- “The `` has returned.” +- “Only the `` may ``.” + +### Preliminary conflict result + +State: + +- exact and close variants checked; +- major storefront results; +- official trademark databases and jurisdictions checked; +- domain/handle observations; +- close-name watch items; +- careful conclusion: “no obvious exact conflict found in this preliminary screen.” + +## Verified naming territory + +| Territory | Source evidence | Naming implication | +|---|---|---| +| Protagonist function | | | +| Return, wound, or curse | | | +| World metaphysics | | | +| Central motifs | | | +| Enemy-owned vocabulary | | | +| Emotional tone | | | + +### Unsupported territory + +List tempting ideas the source does not establish. + +### Vocabulary + +**Own:** + +**Avoid:** + +## Candidate field + +Provide at least 30 candidates across multiple structures. + +### First screen + +Summarize semantic, phonetic, genericness, famous-property, and dialogue cuts. + +## Researched shortlist + +| Candidate | Meaning and spoken result | Current collision result | +|---|---|---| +| | | | + +## Comparison + +| Candidate | Lore | Distinct | Spoken | UI | Search | TM | Store | Domain/handle | Total | Status | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| | | | | | | | | | | | + +## Winner in dialogue and UI + +| Context | Test line | Result | +|---|---|---| +| Character select | | | +| NPC address | | | +| Death/revival | | | +| Inventory | | | +| Quest | | | +| Return announcement | | | +| Boss taunt | | | + +### Capitalization and article + +Document canonical form, narrative article form, direct address, UI form, and game-title pairing. + +## Rejection ledger + +| Candidate | Why it looked good | Why it failed | +|---|---|---| +| | | | + +## Research record + +List exact queries, storefronts, trademark databases, domain RDAP routes, handle checks, dates, jurisdictions, blocked checks, and direct source links. + +## Required next step + +Require counsel-led expanded clearance before adoption or filing. State that no production rename was performed. + +## Decision + +For a completed preliminary screen, end with the exact recommended name on its +own line. + +For a degraded or blocked screen, end with: + +`Research-incomplete creative front-runner: ` diff --git a/agent-skills/game-development/develop-game-epithets/references/research-checklist.md b/agent-skills/game-development/develop-game-epithets/references/research-checklist.md new file mode 100644 index 0000000..fd327f1 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/references/research-checklist.md @@ -0,0 +1,143 @@ +# Preliminary naming research checklist + +Use this checklist for each shortlisted candidate. Record the date, query, result, source URL, jurisdiction, and confidence. + +## Contents + +- Query matrix +- Entertainment and brand surface +- Storefront and catalog routes +- Official trademark sources +- Registry RDAP +- Handle checks +- Minimum evidence and degraded mode +- Risk labels + +## Query matrix + +Search all applicable forms: + +- `"ExactCandidate"` +- `"Exact Candidate"` +- hyphenated form +- singular and plural +- likely misspellings +- likely phonetic spellings +- likely concatenations +- `"candidate" game character studio novel music tabletop brand trademark` +- `site: "candidate"` + +Search strong components separately when they are already associated with entertainment properties. + +## Entertainment and brand surface + +Check: + +- broad web; +- games and characters; +- developers, publishers, and studios; +- novels and comics; +- bands, artists, albums, and songs; +- tabletop games, miniatures, and fantasy products; +- active companies and consumer brands; +- generated-name lists only as low-weight originality evidence. + +An exact same-field use is normally a rejection. A close spelling or phonetic match needs a documented risk judgment. + +## Storefront and catalog routes + +Replace `` with a URL-encoded candidate: + +- Steam: `https://store.steampowered.com/search/?term=` +- itch.io: `https://itch.io/search?q=` +- Epic: `https://store.epicgames.com/en-US/browse?q=` +- GOG: `https://www.gog.com/en/games?query=` +- PlayStation: `https://store.playstation.com/en-us/search/` +- Xbox: `https://www.xbox.com/en-US/Search/Results?q=` +- Nintendo: `https://www.nintendo.com/us/search/#q=&cat=gme` +- Apple Search API: `https://itunes.apple.com/search?term=&entity=software&limit=25` +- Google Play: `https://play.google.com/store/search?q=&c=apps` + +Record explicit zero counts when a storefront exposes them. For fuzzy results, distinguish the returned title from the exact query. + +## Official trademark sources + +### United States + +Use [USPTO Trademark Search](https://tmsearch.uspto.gov/search/search-information). + +Recommended knockout forms: + +```text +CM:candidate +CM:"candidate phrase" +CM:(candidate OR phoneticvariant OR "candidate phrase") +``` + +Search all statuses first. Review likely Nice classes: + +- 9: downloadable game software and related digital goods; +- 41: entertainment and game services; +- 28: toys, games, and tabletop goods. + +Do not exclude related classes merely because they are outside 9, 41, and 28. + +### International and Europe + +- [WIPO Global Brand Database](https://branddb.wipo.int/en/quicksearch) +- [TMview](https://www.tmdn.org/tmview/) +- [EUIPO availability guidance](https://www.euipo.europa.eu/the-office/help-centre/tm/faq-search-availability) +- [UK IPO trademark search](https://trademarks.ipo.gov.uk/ipo-tmtext) + +Record which official interfaces succeeded. TMview is a useful multi-office screen but is not itself an official register and has no legal effect. + +## Registry RDAP + +Use registry RDAP rather than a registrar marketing page: + +- `.com`: `https://rdap.verisign.com/com/v1/domain/.com` +- `.game`: `https://rdap.centralnic.com/game/domain/.game` +- `.games`: `https://rdap.identitydigital.services/rdap/domain/.games` + +An HTTP 404 means no registry object was returned at that moment. It does not guarantee that registration is possible or reserve the name. + +## Handle checks + +Check exact routes where feasible: + +- X: `https://x.com/` +- YouTube: `https://www.youtube.com/@` + +Treat HTTP status as a weak route signal only. A `404`, redirect, login wall, +or successful response does not establish handle ownership or availability. +Confirm the rendered profile and owner manually before making even a practical +claim, and describe an unconfirmed response only as a route status. + +## Minimum evidence and degraded mode + +A completed preliminary screen needs documented current evidence from: + +1. broad-web exact and close-variant searches; +2. the major game storefronts and catalogs relevant to the release; +3. official trademark interfaces for the core target jurisdictions, including + the United States and the applicable UK, EU, or international route; +4. registry RDAP for the selected domains. + +Handle checks are useful but optional and volatile. + +If one of the four required categories is materially unavailable, label the +result **degraded**. Give the best creative front-runner only as +research-incomplete, enumerate the missing sources, and require a refreshed +screen before recommending production adoption. If no current conflict +research can be completed, label the screen **blocked** and do not describe any +candidate as screened. + +## Risk labels + +Use: + +- **Low–moderate:** no obvious exact conflict, but screening remains incomplete. +- **Moderate:** close spelling, component crowding, occupied handles, or uncertain official coverage. +- **High/reject:** meaningful same-field entertainment, game, character, or active-brand conflict. + +Never use “legally clear,” “available,” or “safe.” diff --git a/agent-skills/game-development/develop-game-epithets/scripts/knockout_screen.py b/agent-skills/game-development/develop-game-epithets/scripts/knockout_screen.py new file mode 100644 index 0000000..adaaaf8 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/scripts/knockout_screen.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Best-effort exact-surface checks for game-name candidates. + +This helper is evidence collection, not trademark clearance. It intentionally +does not make availability or legal conclusions. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import html +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass +from typing import Any + + +USER_AGENT = "Mozilla/5.0 (compatible; CodexNamingScreen/1.0)" + + +@dataclass +class HttpResult: + status: int | None + body: str | None + error: str | None + + +@dataclass +class CandidateResult: + candidate: str + slug: str + checked_at: str + steam_url: str + steam_match_count: int | None + itch_url: str + itch_titles: list[str] + itch_exact_title: bool + apple_url: str + apple_exact_titles: list[str] + rdap_status: dict[str, int | None] + handle_status: dict[str, int | None] + errors: list[str] + + +def slugify(value: str) -> str: + return "".join(ch for ch in value.casefold() if ch.isascii() and ch.isalnum()) + + +def fetch(url: str, timeout: float) -> HttpResult: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + charset = response.headers.get_content_charset() or "utf-8" + body = response.read().decode(charset, errors="replace") + return HttpResult(response.status, body, None) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return HttpResult(exc.code, body, None) + except Exception as exc: # Network and TLS errors are evidence gaps. + return HttpResult(None, None, f"{type(exc).__name__}: {exc}") + + +def parse_steam_count(body: str | None) -> int | None: + if not body: + return None + match = re.search(r"([\d,]+)\s+results?\s+match\s+your\s+search", body, re.I) + return int(match.group(1).replace(",", "")) if match else None + + +def parse_itch_titles(body: str | None) -> list[str]: + if not body: + return [] + matches = re.findall( + r'class="game_title">\s*]*>([^<]+)', + body, + flags=re.I, + ) + return [html.unescape(re.sub(r"\s+", " ", value)).strip() for value in matches] + + +def parse_apple_exact(body: str | None, candidate: str) -> list[str]: + if not body: + return [] + try: + payload = json.loads(body) + except json.JSONDecodeError: + return [] + titles = [ + item.get("trackName", "") + for item in payload.get("results", []) + if item.get("trackName", "").casefold() == candidate.casefold() + ] + return [title for title in titles if title] + + +def check_candidate(candidate: str, timeout: float) -> CandidateResult: + slug = slugify(candidate) + query = urllib.parse.quote_plus(candidate) + checked_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + errors: list[str] = [] + + steam_url = f"https://store.steampowered.com/search/?term={query}" + itch_url = f"https://itch.io/search?q={query}" + apple_url = ( + "https://itunes.apple.com/search?" + f"term={query}&entity=software&limit=25" + ) + + steam = fetch(steam_url, timeout) + itch = fetch(itch_url, timeout) + apple = fetch(apple_url, timeout) + + for label, result in (("steam", steam), ("itch", itch), ("apple", apple)): + if result.error: + errors.append(f"{label}: {result.error}") + + itch_titles = parse_itch_titles(itch.body) + + rdap_urls = { + "com": f"https://rdap.verisign.com/com/v1/domain/{slug}.com", + "game": f"https://rdap.centralnic.com/game/domain/{slug}.game", + "games": ( + "https://rdap.identitydigital.services/rdap/domain/" + f"{slug}.games" + ), + } + rdap_status: dict[str, int | None] = {} + for label, url in rdap_urls.items(): + result = fetch(url, timeout) + rdap_status[label] = result.status + if result.error: + errors.append(f"rdap_{label}: {result.error}") + + handle_urls = { + "x": f"https://x.com/{slug}", + "youtube": f"https://www.youtube.com/@{slug}", + } + handle_status: dict[str, int | None] = {} + for label, url in handle_urls.items(): + result = fetch(url, timeout) + handle_status[label] = result.status + if result.error: + errors.append(f"handle_{label}: {result.error}") + + return CandidateResult( + candidate=candidate, + slug=slug, + checked_at=checked_at, + steam_url=steam_url, + steam_match_count=parse_steam_count(steam.body), + itch_url=itch_url, + itch_titles=itch_titles, + itch_exact_title=any( + title.casefold() == candidate.casefold() for title in itch_titles + ), + apple_url=apple_url, + apple_exact_titles=parse_apple_exact(apple.body, candidate), + rdap_status=rdap_status, + handle_status=handle_status, + errors=errors, + ) + + +def render_markdown(results: list[CandidateResult]) -> str: + lines = [ + "| Candidate | Steam count | itch exact | Apple exact | RDAP com/game/games | X/YT route status |", + "|---|---:|---|---:|---|---|", + ] + for result in results: + rdap = "/".join( + str(result.rdap_status.get(key) or "error") + for key in ("com", "game", "games") + ) + handles = "/".join( + str(result.handle_status.get(key) or "error") + for key in ("x", "youtube") + ) + steam = ( + str(result.steam_match_count) + if result.steam_match_count is not None + else "unknown" + ) + lines.append( + f"| {result.candidate} | {steam} | " + f"{'yes' if result.itch_exact_title else 'no'} | " + f"{len(result.apple_exact_titles)} | {rdap} | {handles} |" + ) + + lines.extend( + [ + "", + "Interpretation guardrails:", + "", + "- `404` from RDAP means no registry object was returned at check time.", + "- Store and handle checks are best-effort and may be fuzzy, blocked, or regional.", + "- X/YouTube status codes are route signals, not handle ownership or availability.", + "- This output is preliminary evidence, not availability or legal clearance.", + ] + ) + + errors = [ + f"- {result.candidate}: {error}" + for result in results + for error in result.errors + ] + if errors: + lines.extend(["", "Evidence gaps:", "", *errors]) + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Collect best-effort exact storefront, RDAP, and handle signals." + ) + parser.add_argument("candidates", nargs="+", help="Candidate names to check") + parser.add_argument( + "--timeout", + type=float, + default=12.0, + help="Per-request timeout in seconds (default: 12)", + ) + parser.add_argument( + "--format", + choices=("json", "markdown"), + default="json", + help="Output format (default: json)", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + invalid = [value for value in args.candidates if not slugify(value)] + if invalid: + print(f"Candidates must contain ASCII letters or digits: {invalid}", file=sys.stderr) + return 2 + + results = [check_candidate(value, args.timeout) for value in args.candidates] + if args.format == "markdown": + print(render_markdown(results)) + else: + payload: list[dict[str, Any]] = [asdict(result) for result in results] + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-skills/game-development/develop-game-epithets/scripts/validate_candidate_field.py b/agent-skills/game-development/develop-game-epithets/scripts/validate_candidate_field.py new file mode 100644 index 0000000..a47b570 --- /dev/null +++ b/agent-skills/game-development/develop-game-epithets/scripts/validate_candidate_field.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Validate hard lexical constraints across a proposed candidate field.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass + + +@dataclass +class Violation: + candidate: str + issue: str + term: str | None = None + + +def normalize(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def contains(candidate: str, term: str, mode: str) -> bool: + if mode == "substring": + return term.casefold() in candidate.casefold() + words = re.findall(r"[^\W_]+", candidate.casefold(), flags=re.UNICODE) + return term.casefold() in words + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Check candidate count, uniqueness, and required or banned terms. " + "Reads non-empty newline-delimited candidates from stdin when no " + "positional candidates are provided." + ) + ) + parser.add_argument("candidates", nargs="*", help="Candidate names") + parser.add_argument( + "--require", + action="append", + default=[], + metavar="TERM", + help="Term every candidate must contain; repeat as needed", + ) + parser.add_argument( + "--ban", + action="append", + default=[], + metavar="TERM", + help="Term no candidate may contain; repeat as needed", + ) + parser.add_argument( + "--min-count", + type=int, + default=30, + help="Minimum number of unique candidates (default: 30)", + ) + parser.add_argument( + "--match", + choices=("substring", "word"), + default="substring", + help="Constraint matching mode (default: substring)", + ) + parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + help="Output format (default: text)", + ) + return parser.parse_args() + + +def read_candidates(args: argparse.Namespace) -> list[str]: + raw = args.candidates + if not raw and not sys.stdin.isatty(): + raw = sys.stdin.read().splitlines() + return [normalized for value in raw if (normalized := normalize(value))] + + +def validate( + candidates: list[str], + required: list[str], + banned: list[str], + min_count: int, + mode: str, +) -> list[Violation]: + violations: list[Violation] = [] + seen: dict[str, str] = {} + + for candidate in candidates: + key = candidate.casefold() + if key in seen: + violations.append( + Violation(candidate, f"duplicate of {seen[key]!r}") + ) + else: + seen[key] = candidate + + for term in required: + if not contains(candidate, term, mode): + violations.append(candidate_violation(candidate, "missing required term", term)) + for term in banned: + if contains(candidate, term, mode): + violations.append(candidate_violation(candidate, "contains banned term", term)) + + if len(seen) < min_count: + violations.append( + Violation( + "", + f"only {len(seen)} unique candidates; minimum is {min_count}", + ) + ) + return violations + + +def candidate_violation(candidate: str, issue: str, term: str) -> Violation: + return Violation(candidate=candidate, issue=issue, term=term) + + +def main() -> int: + args = parse_args() + if args.min_count < 1: + print("--min-count must be at least 1", file=sys.stderr) + return 2 + + candidates = read_candidates(args) + if not candidates: + print("No candidates supplied", file=sys.stderr) + return 2 + + violations = validate( + candidates, + required=[normalize(term) for term in args.require if normalize(term)], + banned=[normalize(term) for term in args.ban if normalize(term)], + min_count=args.min_count, + mode=args.match, + ) + + if args.format == "json": + print( + json.dumps( + { + "passed": not violations, + "candidate_count": len(candidates), + "unique_count": len({value.casefold() for value in candidates}), + "violations": [asdict(item) for item in violations], + }, + indent=2, + ensure_ascii=False, + ) + ) + elif violations: + print("Candidate field failed:") + for item in violations: + suffix = f": {item.term}" if item.term else "" + print(f"- {item.candidate}: {item.issue}{suffix}") + else: + print( + f"Candidate field passed: {len(candidates)} candidates, " + f"{len({value.casefold() for value in candidates})} unique." + ) + + return 1 if violations else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-skills/media/attach-lesson-project-files/SKILL.md b/agent-skills/media/attach-lesson-project-files/SKILL.md new file mode 100644 index 0000000..e39ca64 --- /dev/null +++ b/agent-skills/media/attach-lesson-project-files/SKILL.md @@ -0,0 +1,57 @@ +--- +name: attach-lesson-project-files +description: Find, verify, package, attach, and read back original downloadable project files for existing lessons or tutorials. Use when a section needs its matching HTML, source project, supplied archive, prompt, or companion files; do not recreate missing originals from screenshots or video frames. +--- + +# Attach Lesson Project Files + +Give each lesson its real project files in a format learners can use. Preserve provenance, protected access, existing section data, and unrelated worktree changes. + +## Operating contract + +- Start read-only for planning, inventory, search, or comparison. +- Packaging, upload, record attachment, access changes, and publication are separate actions. +- Attach only first-party material, author-supplied downloads, or sources explicitly shared for reuse. +- Never reconstruct a missing project from screenshots or video frames. +- Preserve every field and download outside the exact attachment scope. + +## 1. Establish the lesson inventory + +For every section record identity, current downloads, source video and description, filenames shown, candidate local folders or supplied links, provenance, confidence, package format, and anything missing. + +Require at least two agreeing identity signals, such as an exact filename, source path, description link, matching title and content, author identity, or explicit sharing flag. + +## 2. Choose the learner format + +- Use direct HTML only when it is the complete runnable project. +- Use one ZIP for multi-file HTML, mixed resources, or supplied file sets. +- Use a complete source ZIP for framework projects, including manifests and configuration while excluding dependencies, builds, caches, repositories, and real environment files. + +Do not flatten a framework project into one HTML file or include generated dependencies to make a package appear complete. + +## 3. Verify and package + +Hash recovered sources, inspect existing archives, verify required sibling files, run an appropriate build when practical, and scan for secrets, credentials, private data, and machine paths. + +```bash +python3 scripts/package_project.py \ + --source /path/to/project \ + --root-name learner-project \ + --output /path/to/learner-project.zip +``` + +Use repeated `--file` arguments for a few verified files from different locations. + +## 4. Plan before attachment + +Return a section table with artifact, evidence, origin, package type, included files, checksum, and status: ready, ambiguous, or unavailable. Ask only when ambiguity changes what would be distributed. + +## 5. Attach only when authorized + +Read [publishing-contract.md](references/publishing-contract.md). Use a destination adapter that is dry-run by default, uploads verified bytes, patches only the attachment field, and supports verify-only read-back. + +## 6. Read back and render + +Read the changed record, download the stored object, compare its checksum, verify access policy, and inspect the exact learner route in the approved browser. Persistence, stored bytes, and authenticated rendering are separate proof dimensions. + +Report attached files, untouched sections, provenance, checksums, tests, fields changed, browser limitations, and commit hash. diff --git a/agent-skills/media/attach-lesson-project-files/agents/openai.yaml b/agent-skills/media/attach-lesson-project-files/agents/openai.yaml new file mode 100644 index 0000000..576b388 --- /dev/null +++ b/agent-skills/media/attach-lesson-project-files/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Attach Lesson Project Files" + short_description: "Package and attach verified lesson sources" + default_prompt: "Use $attach-lesson-project-files to find and package the verified source files for these lessons." diff --git a/agent-skills/media/attach-lesson-project-files/references/publishing-contract.md b/agent-skills/media/attach-lesson-project-files/references/publishing-contract.md new file mode 100644 index 0000000..466df2c --- /dev/null +++ b/agent-skills/media/attach-lesson-project-files/references/publishing-contract.md @@ -0,0 +1,23 @@ +# Attachment publishing adapter contract + +Read this only after remote upload and attachment are authorized. + +## Scope the write + +- Resolve the exact parent and lesson identities from the live destination. +- Capture a pre-write receipt for every protected field. +- Patch only the attachment collection and required audit fields. +- Preserve existing attachments unless an exact owned entry is being replaced. +- Do not expose a public URL when the destination requires protected delivery. + +## Required modes + +- default dry-run: validate source checksums, destination identity, access, and write plan; +- write: upload verified bytes and patch only the attachment field; +- verify-only: read records, download stored bytes, and compare checksums. + +Use stable object identities for safe retry. If upload succeeds but record attachment fails, report the unattached object and reread state before retrying or cleaning up. + +## Verification + +Confirm stored bytes, metadata, access policy, learner-facing filename and size, record read-back, and authenticated route behavior when access is available. diff --git a/agent-skills/media/attach-lesson-project-files/scripts/package_project.py b/agent-skills/media/attach-lesson-project-files/scripts/package_project.py new file mode 100755 index 0000000..f3085d3 --- /dev/null +++ b/agent-skills/media/attach-lesson-project-files/scripts/package_project.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Create a deterministic, source-only ZIP for a lesson project.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0) +EXCLUDED_PARTS = { + ".git", + ".next", + ".nuxt", + ".parcel-cache", + ".turbo", + ".vercel", + "__MACOSX", + "build", + "coverage", + "dist", + "node_modules", +} +EXCLUDED_NAMES = {".DS_Store", ".npmrc", ".yarnrc"} +ALLOWED_ENV_EXAMPLES = {".env.example", ".env.sample", ".env.template"} +SENSITIVE_SUFFIXES = {".key", ".p12", ".pem"} +TEXT_SCAN_LIMIT = 2 * 1024 * 1024 +SECRET_PATTERNS = [ + ("private key", re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")), + ("AWS access key", re.compile(rb"\bAKIA[0-9A-Z]{16}\b")), + ("GitHub token", re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b")), + ("Stripe secret key", re.compile(rb"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b")), + ("service-account private key", re.compile(rb'"private_key"\s*:\s*"-----BEGIN')), +] + + +@dataclass(frozen=True) +class PackageFile: + source: Path + archive_path: PurePosixPath + + +def is_environment_secret(name: str) -> bool: + return name == ".env" or (name.startswith(".env.") and name not in ALLOWED_ENV_EXAMPLES) + + +def exclusion_reason(relative_path: Path) -> str | None: + if any(part in EXCLUDED_PARTS for part in relative_path.parts): + return "generated or transport-only directory" + if relative_path.name in EXCLUDED_NAMES: + return "transport metadata or credential-bearing config" + if is_environment_secret(relative_path.name): + return "environment file" + if relative_path.suffix.lower() in SENSITIVE_SUFFIXES: + return "credential-like file" + if relative_path.name.lower().startswith(("service-account", "service_account", "credentials")): + return "credential-like file" + return None + + +def scan_for_secrets(file_path: Path) -> None: + if file_path.stat().st_size > TEXT_SCAN_LIMIT: + return + data = file_path.read_bytes() + if b"\x00" in data: + return + for label, pattern in SECRET_PATTERNS: + if pattern.search(data): + raise ValueError(f"Refusing to package suspected {label} in {file_path}") + + +def collect_directory( + source_dir: Path, root_name: str, output: Path +) -> tuple[list[PackageFile], list[dict[str, str]]]: + if output == source_dir or source_dir in output.parents: + raise ValueError("Output ZIP must be outside the source directory") + + files: list[PackageFile] = [] + excluded: list[dict[str, str]] = [] + for current_root, directory_names, file_names in os.walk( + source_dir, topdown=True, followlinks=False + ): + current = Path(current_root) + kept_directories: list[str] = [] + for directory_name in sorted(directory_names): + candidate = current / directory_name + relative = candidate.relative_to(source_dir) + reason = exclusion_reason(relative) + if reason: + excluded.append({"path": relative.as_posix(), "reason": reason}) + continue + if candidate.is_symlink(): + raise ValueError(f"Refusing to package symlink {candidate}") + kept_directories.append(directory_name) + directory_names[:] = kept_directories + + for file_name in sorted(file_names): + candidate = current / file_name + relative = candidate.relative_to(source_dir) + reason = exclusion_reason(relative) + if reason: + excluded.append({"path": relative.as_posix(), "reason": reason}) + continue + if candidate.is_symlink(): + raise ValueError(f"Refusing to package symlink {candidate}") + scan_for_secrets(candidate) + files.append(PackageFile(candidate, PurePosixPath(root_name, *relative.parts))) + return files, excluded + + +def collect_explicit( + source_files: list[Path], root_name: str +) -> tuple[list[PackageFile], list[dict[str, str]]]: + files: list[PackageFile] = [] + archive_names: set[str] = set() + excluded: list[dict[str, str]] = [] + for candidate in source_files: + if candidate.is_symlink(): + raise ValueError(f"Refusing to package symlink {candidate}") + if not candidate.is_file(): + raise ValueError(f"Source file does not exist: {candidate}") + reason = exclusion_reason(Path(candidate.name)) + if reason: + excluded.append({"path": str(candidate), "reason": reason}) + continue + if candidate.name in archive_names: + raise ValueError(f"Duplicate basename in explicit files: {candidate.name}") + scan_for_secrets(candidate) + archive_names.add(candidate.name) + files.append(PackageFile(candidate, PurePosixPath(root_name, candidate.name))) + return files, excluded + + +def write_package(files: list[PackageFile], output: Path) -> dict[str, object]: + if not files: + raise ValueError("No distributable source files remain after exclusions") + + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile( + output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 + ) as archive: + for package_file in sorted(files, key=lambda item: item.archive_path.as_posix()): + info = zipfile.ZipInfo(package_file.archive_path.as_posix(), FIXED_ZIP_TIME) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + source_mode = package_file.source.stat().st_mode + permissions = 0o755 if source_mode & stat.S_IXUSR else 0o644 + info.external_attr = (stat.S_IFREG | permissions) << 16 + archive.writestr( + info, + package_file.source.read_bytes(), + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) + + data = output.read_bytes() + ordered = sorted(files, key=lambda item: item.archive_path.as_posix()) + return { + "output": str(output), + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "fileCount": len(files), + "files": [item.archive_path.as_posix() for item in ordered], + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + source_group = parser.add_mutually_exclusive_group(required=True) + source_group.add_argument("--source", type=Path, help="Project directory to package") + source_group.add_argument( + "--file", + action="append", + type=Path, + dest="files", + help="Verified source file; repeat as needed", + ) + parser.add_argument("--root-name", required=True, help="Top-level folder name inside the ZIP") + parser.add_argument("--output", required=True, type=Path, help="Destination .zip path") + return parser.parse_args(argv) + + +def normalized_root_name(value: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-.") + if not normalized or normalized in {".", ".."}: + raise ValueError("Root name must contain a safe filename character") + return normalized + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + root_name = normalized_root_name(args.root_name) + output = args.output.expanduser().resolve() + + if output.suffix.lower() != ".zip": + raise ValueError("Output must use the .zip extension") + if args.source: + source = args.source.expanduser().resolve() + if not source.is_dir(): + raise ValueError(f"Source directory does not exist: {source}") + files, excluded = collect_directory(source, root_name, output) + else: + files, excluded = collect_explicit( + [item.expanduser().resolve() for item in args.files], root_name + ) + + result = write_package(files, output) + result["excluded"] = excluded + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, zipfile.BadZipFile) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/agent-skills/media/attach-lesson-project-files/scripts/test_package_project.py b/agent-skills/media/attach-lesson-project-files/scripts/test_package_project.py new file mode 100755 index 0000000..b133ee7 --- /dev/null +++ b/agent-skills/media/attach-lesson-project-files/scripts/test_package_project.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("package_project.py") + + +class PackageProjectTests(unittest.TestCase): + def run_packager( + self, *arguments: str, expect_success: bool = True + ) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["python3", str(SCRIPT), *arguments], + check=False, + capture_output=True, + text=True, + ) + if expect_success and result.returncode != 0: + self.fail(result.stderr) + return result + + def test_directory_package_is_deterministic_and_excludes_generated_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + source = root / "react-project" + (source / "src").mkdir(parents=True) + (source / "node_modules" / "package").mkdir(parents=True) + (source / "src" / "App.jsx").write_text( + "export default function App() { return
Hello
; }\n" + ) + (source / "package.json").write_text('{"scripts":{"build":"vite build"}}\n') + (source / "node_modules" / "package" / "index.js").write_text("generated\n") + (source / ".env").write_text("SECRET=do-not-package\n") + first = root / "first.zip" + second = root / "second.zip" + + first_result = self.run_packager( + "--source", + str(source), + "--root-name", + "react-project", + "--output", + str(first), + ) + second_result = self.run_packager( + "--source", + str(source), + "--root-name", + "react-project", + "--output", + str(second), + ) + first_receipt = json.loads(first_result.stdout) + second_receipt = json.loads(second_result.stdout) + + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_receipt["sha256"], second_receipt["sha256"]) + self.assertEqual( + first_receipt["files"], + ["react-project/package.json", "react-project/src/App.jsx"], + ) + self.assertEqual( + {item["path"] for item in first_receipt["excluded"]}, + {".env", "node_modules"}, + ) + + def test_explicit_files_preserve_a_clean_root_folder(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + html = root / "index.html" + prompt = root / "prompt.md" + output = root / "project.zip" + html.write_text("Project\n") + prompt.write_text("# Prompt\n") + + self.run_packager( + "--file", + str(html), + "--file", + str(prompt), + "--root-name", + "lesson-project", + "--output", + str(output), + ) + with zipfile.ZipFile(output) as archive: + self.assertEqual( + archive.namelist(), + ["lesson-project/index.html", "lesson-project/prompt.md"], + ) + + def test_suspected_secret_stops_packaging(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + source = root / "project" + source.mkdir() + (source / "config.js").write_text( + 'export const token = "ghp_abcdefghijklmnopqrstuvwxyz123456";\n' + ) + result = self.run_packager( + "--source", + str(source), + "--root-name", + "project", + "--output", + str(root / "project.zip"), + expect_success=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("suspected GitHub token", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent-skills/media/build-video-course-lessons/SKILL.md b/agent-skills/media/build-video-course-lessons/SKILL.md new file mode 100644 index 0000000..8b5ea2d --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/SKILL.md @@ -0,0 +1,71 @@ +--- +name: build-video-course-lessons +description: Turn completed lesson videos and verified transcripts into source-faithful written companions with evidence frames, provenance manifests, optional editor-project adapters, content-only publication, and rendered verification. Use for authoring or replacing lesson articles; do not use for video editing or broad course migrations. +--- + +# Build Video Course Lessons + +Use the exact final video and its transcript as required evidence. An editor project may provide cleaner source frames, but it is optional and must declare its adapter honestly. + +## Operating contract + +- Use the approved browser for browser work. +- Keep local Markdown as the authored source. +- Do not invent steps, UI states, quotes, resources, or outcomes. +- Treat uploads, article publication, access changes, video replacement, and course restructuring as separate actions. +- Preserve unrelated worktree changes and commit only the lesson scope. + +## Load relevant support + +- Read [package-contract.md](references/package-contract.md) before extracting evidence. +- Read [content-only-publishing.md](references/content-only-publishing.md) only when remote publication is requested. +- Use `scripts/extract_lesson_sources.py` for final-video packages. +- Use `scripts/validate_course_lesson.py` for package validation. + +## 1. Establish source identity + +For every lesson, record its title, slug, exact final video, checksum when available, transcript source, optional editor project and adapter, destination identity, access, and current article version. + +Probe the final video with `ffprobe`. Stop if the master cannot be matched confidently. An unavailable editor project does not block writing when the final video and transcript are verified. + +## 2. Choose an evidence mode + +- **Final video:** required baseline. Extract frames directly from the verified master and record their exact timestamps. +- **Editor project:** optional. Use only through an adapter that maps timeline time to source media and emits the package contract. Record what the adapter omits or preserves. + +Never describe a baked final-master frame as a clean source frame. Never imply an editor adapter preserved effects it deliberately removed. + +## 3. Build the lesson package + +Create a config from [package-contract.md](references/package-contract.md), then run: + +```bash +python3 scripts/extract_lesson_sources.py --config /path/to/lesson-sources.json +``` + +Choose timestamps that prove decisions, workflow states, corrections, or results. Reject loading states, obscured controls, irrelevant menus, and near-duplicates. + +## 4. Plan the written companion + +Define the reader, useful outcome, and source-backed section sequence. Use only as many sections and visuals as the lesson needs. A cover is optional when the destination supplies its own hero. + +Use real frames for UI, process, and result evidence. Generate a conceptual companion only when it explains something the recording cannot show, and label it as generated rather than recorded evidence. + +## 5. Write from the lesson + +- Follow the video's actual decisions and order. +- Use concrete headings and short paragraphs. +- Link the first mention of relevant tools and resources. +- Put a Resources section last when the source includes resources. +- Keep operational notes and social copy outside the learner article. +- Follow the destination's established voice; do not impose a personal style profile. + +## 6. Validate and publish narrowly + +Run the package validator and the destination project's focused checks. When publication is authorized, use a content-only adapter that validates every package before writing, uploads only article assets, and patches only allowlisted article fields. + +After publication, compare content hashes, prove protected fields unchanged, and render the exact lesson route. Database read-back does not prove authenticated or responsive rendering. + +## Deliver + +Report source videos, transcripts, evidence mode, adapter when used, capture provenance, validation, published fields, protected fields, browser limitations, and commit hash. diff --git a/agent-skills/media/build-video-course-lessons/agents/openai.yaml b/agent-skills/media/build-video-course-lessons/agents/openai.yaml new file mode 100644 index 0000000..df31dd3 --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Build Video Course Lessons" + short_description: "Create evidence-backed lesson companions" + default_prompt: "Use $build-video-course-lessons to turn these completed lesson videos into verified written companions." diff --git a/agent-skills/media/build-video-course-lessons/references/content-only-publishing.md b/agent-skills/media/build-video-course-lessons/references/content-only-publishing.md new file mode 100644 index 0000000..08ca5cf --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/references/content-only-publishing.md @@ -0,0 +1,16 @@ +# Content-only publishing + +Read this only after the user authorizes remote article publication. + +## Adapter requirements + +The publisher must be dry-run by default and expose write plus verify-only modes. It must discover the destination schema, validate all packages before the first mutation, upload only article assets, rewrite only publication copies of Markdown, and patch only an explicit article-field allowlist. + +Capture a pre-write receipt for course identity, lesson identity, video, chapters, access, order, pricing, instructor, cover, downloads, and publication state. Require those protected values to remain unchanged after the write. + +## Failure behavior + +- If asset upload succeeds but the record patch fails, report the exact unattached objects and reread state before retrying. +- If one lesson fails preflight, write none of the batch unless partial publication was explicitly requested. +- If authenticated rendering is unavailable, report persistence and public-route evidence separately. +- Never use a broad course importer to publish article content. diff --git a/agent-skills/media/build-video-course-lessons/references/package-contract.md b/agent-skills/media/build-video-course-lessons/references/package-contract.md new file mode 100644 index 0000000..d8ad822 --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/references/package-contract.md @@ -0,0 +1,61 @@ +# Video lesson package contract + +## Directory shape + +```text +course-slug/ +└── lesson-slug/ + ├── content.md + ├── manifest.json + ├── transcript.txt + └── images/ + ├── source-0034s.jpg + └── finished-result.jpg +``` + +`content.md` is the authored source. `transcript.txt` is evidence. `manifest.json` records source identity and image order; it must not duplicate the article text. + +## Final-video config + +```json +{ + "course": {"title": "Course title", "slug": "course-slug"}, + "outputRoot": "./content/courses/course-slug", + "lessons": [ + { + "number": 1, + "title": "Lesson title", + "slug": "lesson-slug", + "video": "/path/to/final-master.mp4", + "transcript": "/path/to/verified-transcript.txt", + "expectedVideoSha256": "optional-64-character-sha256", + "captures": [ + {"videoSeconds": 34, "name": "workflow-state", "brief": "The state where the workflow begins"} + ] + } + ] +} +``` + +Relative paths resolve from the config directory. Capture names use lowercase hyphen slugs. Timestamps must fall inside the video duration. + +## Editor-adapter contract + +An editor adapter may build the same directory and manifest, but it must add: + +- `sourceMethod: "editor-project"`; +- `editorAdapter` with `name` and `version`; +- `editorProject` with a project path or stable identity; +- `requestedTimelineSeconds` and `mappedSourceSeconds` for each capture; +- `captureMethod` for each capture; +- `includedEffects` and `excludedEffects` arrays for each capture. + +The generic validator accepts adapter packages but does not validate editor-specific internals. The adapter owns those checks. + +## Manifest invariants + +- exact source video path, checksum, bytes, duration, dimensions, frame rate, and audio presence; +- transcript source and non-empty transcript; +- ordered image list matching Markdown; +- provenance for every extracted or generated visual; +- no credentials, private URLs, or article text. diff --git a/agent-skills/media/build-video-course-lessons/scripts/extract_lesson_sources.py b/agent-skills/media/build-video-course-lessons/scripts/extract_lesson_sources.py new file mode 100644 index 0000000..c20c5b0 --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/scripts/extract_lesson_sources.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Build deterministic lesson evidence packages from verified final videos.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +@dataclass(frozen=True) +class PreparedLesson: + lesson: dict[str, object] + video: Path + transcript: Path + output_dir: Path + video_sha256: str + video_bytes: int + media: dict[str, object] + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=True, capture_output=True, text=True) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_path(raw: object, config_dir: Path, label: str) -> Path: + if not isinstance(raw, str) or not raw.strip(): + raise ValueError(f"{label} must be a non-empty path string") + path = Path(raw).expanduser() + if not path.is_absolute(): + path = config_dir / path + return path.resolve() + + +def require_slug(value: object, label: str) -> str: + if not isinstance(value, str) or not SLUG_RE.fullmatch(value): + raise ValueError(f"{label} must be a lowercase hyphen slug") + return value + + +def probe_video(path: Path) -> dict[str, object]: + result = run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "stream=codec_type,width,height,avg_frame_rate:format=duration", + "-of", + "json", + str(path), + ] + ) + payload = json.loads(result.stdout) + streams = payload.get("streams", []) + video_stream = next( + (stream for stream in streams if stream.get("codec_type") == "video"), None + ) + if video_stream is None: + raise ValueError(f"No video stream found in {path}") + duration = float(payload.get("format", {}).get("duration", 0)) + if duration <= 0: + raise ValueError(f"Could not determine a positive duration for {path}") + return { + "durationSeconds": round(duration, 3), + "width": int(video_stream["width"]), + "height": int(video_stream["height"]), + "frameRate": video_stream.get("avg_frame_rate", "unknown"), + "hasAudio": any(stream.get("codec_type") == "audio" for stream in streams), + } + + +def timestamp_label(seconds: float) -> str: + milliseconds = int(round(seconds * 1000)) + hours, remainder = divmod(milliseconds, 3_600_000) + minutes, remainder = divmod(remainder, 60_000) + whole_seconds, milliseconds = divmod(remainder, 1000) + return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d}.{milliseconds:03d}" + + +def prepare(config_path: Path, force: bool) -> tuple[dict[str, str], list[PreparedLesson]]: + config = json.loads(config_path.read_text(encoding="utf-8")) + config_dir = config_path.parent.resolve() + course_raw = config.get("course") + if not isinstance(course_raw, dict): + raise ValueError("course must be an object") + course_title = course_raw.get("title") + if not isinstance(course_title, str) or not course_title.strip(): + raise ValueError("course.title must be a non-empty string") + course = { + "title": course_title.strip(), + "slug": require_slug(course_raw.get("slug"), "course.slug"), + } + output_root = resolve_path(config.get("outputRoot"), config_dir, "outputRoot") + lessons = config.get("lessons") + if not isinstance(lessons, list) or not lessons: + raise ValueError("lessons must be a non-empty array") + + prepared: list[PreparedLesson] = [] + seen_slugs: set[str] = set() + for index, lesson_raw in enumerate(lessons, start=1): + if not isinstance(lesson_raw, dict): + raise ValueError(f"lessons[{index - 1}] must be an object") + slug = require_slug(lesson_raw.get("slug"), f"lessons[{index - 1}].slug") + if slug in seen_slugs: + raise ValueError(f"Duplicate lesson slug: {slug}") + seen_slugs.add(slug) + title = lesson_raw.get("title") + number = lesson_raw.get("number") + if not isinstance(title, str) or not title.strip(): + raise ValueError(f"Lesson {slug} needs a non-empty title") + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise ValueError(f"Lesson {slug} number must be a positive integer") + + video = resolve_path(lesson_raw.get("video"), config_dir, f"{slug}.video") + transcript = resolve_path( + lesson_raw.get("transcript"), config_dir, f"{slug}.transcript" + ) + if not video.is_file(): + raise ValueError(f"Video does not exist: {video}") + if not transcript.is_file(): + raise ValueError(f"Transcript does not exist: {transcript}") + if not transcript.read_text(encoding="utf-8").strip(): + raise ValueError(f"Transcript is empty: {transcript}") + + video_digest = sha256(video) + expected = lesson_raw.get("expectedVideoSha256") + if expected is not None: + if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", expected): + raise ValueError(f"Lesson {slug} expectedVideoSha256 is invalid") + if video_digest != expected.lower(): + raise ValueError(f"Lesson {slug} video checksum does not match") + + media = probe_video(video) + captures = lesson_raw.get("captures") + if not isinstance(captures, list) or not captures: + raise ValueError(f"Lesson {slug} needs at least one capture") + capture_names: list[str] = [] + for capture_index, capture in enumerate(captures): + if not isinstance(capture, dict): + raise ValueError(f"Lesson {slug} capture {capture_index} must be an object") + name = require_slug(capture.get("name"), f"Lesson {slug} capture name") + if name in capture_names: + raise ValueError(f"Lesson {slug} repeats capture name {name}") + capture_names.append(name) + seconds = capture.get("videoSeconds") + if not isinstance(seconds, (int, float)) or isinstance(seconds, bool): + raise ValueError(f"Lesson {slug} capture {name} needs numeric videoSeconds") + if float(seconds) < 0 or float(seconds) >= float(media["durationSeconds"]): + raise ValueError(f"Lesson {slug} capture {name} is outside the video") + brief = capture.get("brief") + if not isinstance(brief, str) or not brief.strip(): + raise ValueError(f"Lesson {slug} capture {name} needs a brief") + + output_dir = output_root / slug + generated = [output_dir / "manifest.json", output_dir / "transcript.txt"] + generated.extend(output_dir / "images" / f"{name}.jpg" for name in capture_names) + conflicts = [path for path in generated if path.exists()] + if conflicts and not force: + raise ValueError( + f"Lesson {slug} already has generated output; use --force: {conflicts[0]}" + ) + + prepared.append( + PreparedLesson( + lesson={**lesson_raw, "title": title.strip(), "slug": slug, "number": number}, + video=video, + transcript=transcript, + output_dir=output_dir, + video_sha256=video_digest, + video_bytes=video.stat().st_size, + media=media, + ) + ) + return course, prepared + + +def extract_frame(video: Path, seconds: float, output: Path) -> None: + run( + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-ss", + f"{seconds:.3f}", + "-i", + str(video), + "-frames:v", + "1", + "-vf", + "scale=1600:900:force_original_aspect_ratio=decrease," + "pad=1600:900:(ow-iw)/2:(oh-ih)/2", + "-q:v", + "2", + str(output), + ] + ) + + +def write_package(course: dict[str, str], prepared: PreparedLesson) -> Path: + lesson = prepared.lesson + prepared.output_dir.mkdir(parents=True, exist_ok=True) + images_dir = prepared.output_dir / "images" + images_dir.mkdir(exist_ok=True) + capture_records: list[dict[str, object]] = [] + image_paths: list[str] = [] + captures = lesson["captures"] + assert isinstance(captures, list) + for capture in captures: + assert isinstance(capture, dict) + seconds = float(capture["videoSeconds"]) + relative = f"images/{capture['name']}.jpg" + output = prepared.output_dir / relative + extract_frame(prepared.video, seconds, output) + image_paths.append(relative) + capture_records.append( + { + "image": relative, + "brief": capture["brief"], + "videoSeconds": round(seconds, 3), + "sourceTimestamp": timestamp_label(seconds), + "captureMethod": "direct-final-master-frame", + } + ) + + transcript_output = prepared.output_dir / "transcript.txt" + transcript_output.write_text( + prepared.transcript.read_text(encoding="utf-8").rstrip() + "\n", encoding="utf-8" + ) + manifest = { + "course": course, + "lesson": { + "number": lesson["number"], + "title": lesson["title"], + "slug": lesson["slug"], + }, + "sourceMethod": "final-video", + "sourceVideo": { + "path": str(prepared.video), + "sha256": prepared.video_sha256, + "bytes": prepared.video_bytes, + **prepared.media, + }, + "transcriptSource": str(prepared.transcript), + "images": image_paths, + "captures": capture_records, + } + manifest_path = prepared.output_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + content_path = prepared.output_dir / "content.md" + if not content_path.exists(): + lines = [f"# {lesson['title']}", ""] + for capture in capture_records: + lines.extend( + [ + str(capture["brief"]), + "", + f"![{capture['brief']}]({capture['image']})", + "", + ] + ) + content_path.write_text("\n".join(lines), encoding="utf-8") + return manifest_path + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, type=Path) + parser.add_argument( + "--force", action="store_true", help="Replace generated evidence, never content.md" + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + config_path = args.config.expanduser().resolve() + if not config_path.is_file(): + raise ValueError(f"Config does not exist: {config_path}") + for tool in ("ffmpeg", "ffprobe"): + if not shutil.which(tool): + raise ValueError(f"Required tool is missing: {tool}") + course, lessons = prepare(config_path, args.force) + for lesson in lessons: + print(write_package(course, lesson)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + detail = error.stderr.strip() if isinstance(error, subprocess.CalledProcessError) else str(error) + print(f"error: {detail}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/agent-skills/media/build-video-course-lessons/scripts/test_video_course_lessons.py b/agent-skills/media/build-video-course-lessons/scripts/test_video_course_lessons.py new file mode 100644 index 0000000..ca8445e --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/scripts/test_video_course_lessons.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("validate_course_lesson.py") +EXTRACTOR = Path(__file__).with_name("extract_lesson_sources.py") +SPEC = importlib.util.spec_from_file_location("validate_course_lesson", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class VideoCourseLessonTests(unittest.TestCase): + def make_package(self, root: Path, source_method: str = "final-video") -> Path: + package = root / "lesson-one" + (package / "images").mkdir(parents=True) + (package / "content.md").write_text( + "# Lesson one\n\n![A verified state](images/state.jpg)\n", encoding="utf-8" + ) + (package / "transcript.txt").write_text("Verified transcript.\n", encoding="utf-8") + (package / "images" / "state.jpg").write_bytes(b"test image") + manifest: dict[str, object] = { + "course": {"title": "Course", "slug": "course"}, + "lesson": {"number": 1, "title": "Lesson one", "slug": "lesson-one"}, + "sourceMethod": source_method, + "images": ["images/state.jpg"], + } + if source_method == "final-video": + manifest.update( + { + "sourceVideo": { + "path": "/verified/final.mp4", + "sha256": "a" * 64, + "bytes": 100, + "durationSeconds": 10.0, + "width": 1920, + "height": 1080, + "frameRate": "30/1", + "hasAudio": True, + }, + "transcriptSource": "/verified/transcript.txt", + "captures": [ + { + "image": "images/state.jpg", + "brief": "A verified state", + "videoSeconds": 4.0, + "sourceTimestamp": "00:00:04.000", + "captureMethod": "direct-final-master-frame", + } + ], + } + ) + else: + manifest.update( + { + "editorAdapter": {"name": "Example adapter", "version": "1.0"}, + "editorProject": "project-stable-id", + "captures": [ + { + "image": "images/state.jpg", + "brief": "A verified state", + "requestedTimelineSeconds": 4.0, + "mappedSourceSeconds": 7.25, + "captureMethod": "source-frame-through-adapter", + "includedEffects": [], + "excludedEffects": ["captions"], + } + ], + } + ) + (package / "manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + return package + + def test_valid_final_video_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + errors, warnings = MODULE.validate_package( + self.make_package(Path(temporary_dir), "final-video") + ) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_valid_editor_adapter_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + errors, warnings = MODULE.validate_package( + self.make_package(Path(temporary_dir), "editor-project") + ) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_manifest_cannot_hide_article_text(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + package = self.make_package(Path(temporary_dir), "final-video") + manifest_path = package / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["content"] = "Article copy does not belong here." + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + errors, _ = MODULE.validate_package(package) + self.assertTrue(any("article text fields" in error for error in errors)) + + @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg required") + def test_final_video_extractor_builds_a_valid_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + root = Path(temporary_dir) + video = root / "final.mp4" + transcript = root / "transcript.txt" + transcript.write_text("A verified narration transcript.\n", encoding="utf-8") + subprocess.run( + [ + "ffmpeg", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "color=c=blue:s=640x360:d=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=2", + "-shortest", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + str(video), + ], + check=True, + ) + config = root / "sources.json" + config.write_text( + json.dumps( + { + "course": {"title": "Course", "slug": "course"}, + "outputRoot": "./packages/course", + "lessons": [ + { + "number": 1, + "title": "Lesson one", + "slug": "lesson-one", + "video": "./final.mp4", + "transcript": "./transcript.txt", + "captures": [ + { + "videoSeconds": 1, + "name": "verified-state", + "brief": "A verified state", + } + ], + } + ], + } + ), + encoding="utf-8", + ) + result = subprocess.run( + ["python3", str(EXTRACTOR), "--config", str(config)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + package = root / "packages" / "course" / "lesson-one" + errors, warnings = MODULE.validate_package(package) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + manifest = json.loads((package / "manifest.json").read_text(encoding="utf-8")) + self.assertTrue(manifest["sourceVideo"]["hasAudio"]) + self.assertEqual(manifest["captures"][0]["captureMethod"], "direct-final-master-frame") + + +if __name__ == "__main__": + unittest.main() diff --git a/agent-skills/media/build-video-course-lessons/scripts/validate_course_lesson.py b/agent-skills/media/build-video-course-lessons/scripts/validate_course_lesson.py new file mode 100644 index 0000000..8147124 --- /dev/null +++ b/agent-skills/media/build-video-course-lessons/scripts/validate_course_lesson.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Validate neutral final-video and editor-adapter lesson packages.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +FORBIDDEN_MANIFEST_KEYS = {"article", "body", "content", "html", "markdown"} + + +def clean_target(raw: str) -> str: + target = raw.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + return target.split("#", 1)[0].split("?", 1)[0] + + +def find_forbidden_key(value: object, path: str = "manifest") -> str | None: + if isinstance(value, dict): + for key, child in value.items(): + if str(key).lower() in FORBIDDEN_MANIFEST_KEYS: + return f"{path}.{key}" + found = find_forbidden_key(child, f"{path}.{key}") + if found: + return found + elif isinstance(value, list): + for index, child in enumerate(value): + found = find_forbidden_key(child, f"{path}[{index}]") + if found: + return found + return None + + +def require_fields(value: object, fields: set[str], label: str, errors: list[str]) -> None: + if not isinstance(value, dict): + errors.append(f"{label} must be an object") + return + missing = sorted(field for field in fields if field not in value) + if missing: + errors.append(f"{label} is missing: {', '.join(missing)}") + + +def validate_package(package_dir: Path) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + content_path = package_dir / "content.md" + manifest_path = package_dir / "manifest.json" + transcript_path = package_dir / "transcript.txt" + images_dir = package_dir / "images" + for required in (content_path, manifest_path, transcript_path): + if not required.is_file(): + errors.append(f"Missing required file: {required.name}") + if not images_dir.is_dir(): + errors.append("Missing required directory: images") + if errors: + return errors, warnings + + content = content_path.read_text(encoding="utf-8") + first_line = next((line for line in content.splitlines() if line.strip()), "") + if not first_line.startswith("# "): + errors.append("content.md must begin with a non-empty H1") + if not transcript_path.read_text(encoding="utf-8").strip(): + errors.append("transcript.txt must not be empty") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + errors.append(f"manifest.json is invalid JSON: {error}") + return errors, warnings + if not isinstance(manifest, dict): + errors.append("manifest.json must contain an object") + return errors, warnings + + forbidden = find_forbidden_key(manifest) + if forbidden: + errors.append(f"Manifest must not contain article text fields: {forbidden}") + require_fields(manifest.get("course"), {"title", "slug"}, "course", errors) + require_fields(manifest.get("lesson"), {"number", "title", "slug"}, "lesson", errors) + + images = manifest.get("images") + if not isinstance(images, list) or not images or not all(isinstance(item, str) for item in images): + errors.append("images must be a non-empty ordered array of paths") + images = [] + markdown_images = [clean_target(match.group(1)) for match in IMAGE_RE.finditer(content)] + if markdown_images != images: + errors.append("Markdown image order must exactly match manifest images") + for relative in images: + image_path = Path(relative) + if image_path.is_absolute() or ".." in image_path.parts: + errors.append(f"Image path must stay inside the package: {relative}") + continue + if not (package_dir / image_path).is_file(): + errors.append(f"Missing manifest image: {relative}") + + captures = manifest.get("captures") + if not isinstance(captures, list) or len(captures) != len(images): + errors.append("captures must contain one provenance record per image") + captures = [] + else: + capture_images = [item.get("image") if isinstance(item, dict) else None for item in captures] + if capture_images != images: + errors.append("Capture image order must exactly match manifest images") + + source_method = manifest.get("sourceMethod") + if source_method == "final-video": + source_video = manifest.get("sourceVideo") + require_fields( + source_video, + {"path", "sha256", "bytes", "durationSeconds", "width", "height", "frameRate", "hasAudio"}, + "sourceVideo", + errors, + ) + if isinstance(source_video, dict) and not SHA256_RE.fullmatch(str(source_video.get("sha256", ""))): + errors.append("sourceVideo.sha256 must be a lowercase SHA-256") + transcript_source = manifest.get("transcriptSource") + if not isinstance(transcript_source, str) or not transcript_source.strip(): + errors.append("transcriptSource must be a non-empty source identity") + for index, capture in enumerate(captures): + require_fields( + capture, + {"image", "brief", "videoSeconds", "sourceTimestamp", "captureMethod"}, + f"captures[{index}]", + errors, + ) + if isinstance(capture, dict) and capture.get("captureMethod") != "direct-final-master-frame": + errors.append(f"captures[{index}] must declare direct-final-master-frame") + elif source_method == "editor-project": + require_fields(manifest.get("editorAdapter"), {"name", "version"}, "editorAdapter", errors) + editor_project = manifest.get("editorProject") + if not isinstance(editor_project, str) or not editor_project.strip(): + errors.append("editorProject must be a non-empty path or stable identity") + for index, capture in enumerate(captures): + require_fields( + capture, + { + "image", + "brief", + "requestedTimelineSeconds", + "mappedSourceSeconds", + "captureMethod", + "includedEffects", + "excludedEffects", + }, + f"captures[{index}]", + errors, + ) + else: + errors.append("sourceMethod must be final-video or editor-project") + + referenced = {str(Path(item)) for item in images} + extras = sorted( + str(path.relative_to(package_dir)) + for path in images_dir.rglob("*") + if path.is_file() and str(path.relative_to(package_dir)) not in referenced + ) + if extras: + warnings.append(f"Unreferenced image files: {', '.join(extras)}") + return errors, warnings + + +def discover_packages(paths: list[Path]) -> list[Path]: + packages: list[Path] = [] + for raw in paths: + path = raw.expanduser().resolve() + if path.is_file() and path.name in {"content.md", "manifest.json"}: + path = path.parent + if not path.is_dir(): + raise ValueError(f"Package path does not exist: {path}") + if (path / "manifest.json").is_file(): + packages.append(path) + else: + packages.extend(sorted(item.parent for item in path.rglob("manifest.json"))) + unique = list(dict.fromkeys(packages)) + if not unique: + raise ValueError("No lesson packages found") + return unique + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("packages", nargs="+", type=Path) + args = parser.parse_args(argv or sys.argv[1:]) + failed = False + for package in discover_packages(args.packages): + errors, warnings = validate_package(package) + print(f"Package: {package}") + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}") + if errors: + failed = True + print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)") + else: + print(f"PASS: 0 errors, {len(warnings)} warning(s)") + return 1 if failed else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error diff --git a/agent-skills/media/build-video-courses/SKILL.md b/agent-skills/media/build-video-courses/SKILL.md new file mode 100644 index 0000000..bd12933 --- /dev/null +++ b/agent-skills/media/build-video-courses/SKILL.md @@ -0,0 +1,86 @@ +--- +name: build-video-courses +description: Turn an ordered set of completed lesson videos into a draft-first online course with verified media, learner-facing metadata, optional lesson articles and downloads, controlled publication, and rendered read-back. Use for end-to-end course creation across course platforms and video hosts; do not use for editing the source videos or changing one existing lesson only. +--- + +# Build Video Courses + +Build the course as a sequence of verified state transitions. Keep source intake, local authoring, video upload, draft creation, article publication, course publication, and application deployment as separate actions. + +## Keep the boundaries visible + +- Start read-only when the user asks to inspect, list, or plan. +- Planning does not authorize uploads or database writes. A draft does not authorize publication. +- Use the destination project's existing authentication and provider integrations. Never copy credentials into a new importer. +- Keep internal repository names out of learner-facing copy unless the lesson teaches that project. +- Preserve unrelated worktree changes and use narrow commits. + +## Load specialized support only when needed + +- Read [course-publishing-contract.md](references/course-publishing-contract.md) before implementing or running a destination importer. +- Use `$build-video-course-lessons` for transcript-backed written companions. +- Use `$attach-lesson-project-files` for verified downloadable source projects. +- Use `$generate-course-cover-options` when a new cover is needed and wait for selection before applying it. + +## 1. Inventory the exact source set + +Resolve the supplied folder into source order. For every lesson record the exact master path, checksum, bytes, duration, dimensions, frame rate, codecs, audio presence, transcript source, tentative title, and chapter evidence. + +Inspect enough of every video and transcript to understand the course arc. Stop when a master cannot be matched confidently. Never substitute a similarly named export. + +## 2. Agree on the learner-facing course + +Before external writes, present: + +- title choices and a recommended slug; +- the course promise and intended audience; +- source-ordered lesson titles and outcomes; +- instructor, access model, cover direction, and intended publication state; +- missing source, transcript, identity, or destination evidence. + +Wait when title, instructor, access, replacement-versus-new identity, or destination is materially undecided. + +## 3. Inspect the destination adapter + +Discover the current course schema, lesson schema, video host, storage layout, routes, access controls, and existing import commands. Do not assume a database, provider, field name, or URL structure. + +The adapter must expose dry-run, draft-write, publish, and verify-only modes. It must identify records it owns, refuse collisions, preserve protected fields, resume uploads by source checksum, and store receipts without secrets. + +## 4. Dry-run the complete plan + +Validate the local source and read the live destination before any write. Report proposed identities, collisions, access, publication state, source hashes, media properties, chapters, selected assets, and provider objects that can be reused. + +A local JSON check alone is insufficient. The dry-run must prove the intended destination is safe. + +## 5. Create a private draft + +Proceed only after the user authorizes draft creation and uploads. + +1. Create importer-owned draft course and lesson records. +2. Upload or resume missing videos through the active provider adapter. +3. Save each provider receipt immediately. +4. Wait for processing and verify duration, readiness, visibility, and embed policy. +5. Write final draft media fields and source-order summaries. +6. Run verify-only mode and require the draft state. + +Do not restart a completed upload because a later read-back or browser check failed. Recover from the saved provider identity and checksum. + +## 6. Add lesson companions + +After lesson identities and videos are stable, author local articles with `$build-video-course-lessons` and package verified downloads with `$attach-lesson-project-files`. + +Publishing those companions is independently authorized. Use field allowlists so article or attachment writes cannot replace video, access, order, pricing, instructor, cover, or publication state. + +## 7. Publish deliberately + +Publish only after explicit authorization. Require a separate write flag and expected-state assertion. Then read back the course, every lesson, every hosted video, and every changed attachment or article field. + +## 8. Verify the learner experience + +Use the approved browser on the exact destination routes. Verify catalog presence, course identity, cover, instructor, lesson order, duration, access labels, player behavior, chapters, article rendering, downloads, responsive layout, and relevant console output. + +Keep proof dimensions separate: database persistence is not video playback, local rendering is not production deployment, and a signed-out gate is not authenticated access verification. + +## Deliver + +Record source checksums, destination identities, provider receipts, upload reuse, access and publication state, article and attachment status, browser verification, limitations, and scoped commit hashes. diff --git a/agent-skills/media/build-video-courses/agents/openai.yaml b/agent-skills/media/build-video-courses/agents/openai.yaml new file mode 100644 index 0000000..de5b2b7 --- /dev/null +++ b/agent-skills/media/build-video-courses/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Build Video Courses" + short_description: "Create verified draft-first video courses" + default_prompt: "Use $build-video-courses to turn these completed lessons into a verified draft-first course." diff --git a/agent-skills/media/build-video-courses/references/course-publishing-contract.md b/agent-skills/media/build-video-courses/references/course-publishing-contract.md new file mode 100644 index 0000000..7f34cad --- /dev/null +++ b/agent-skills/media/build-video-courses/references/course-publishing-contract.md @@ -0,0 +1,41 @@ +# Course publishing adapter contract + +Read this only when implementing or running a destination adapter. + +## Required modes + +| Mode | Allowed effect | +| --- | --- | +| Dry run | Validate sources and read destination state; no mutation. | +| Draft write | Create or refresh adapter-owned draft records and upload or reuse media. | +| Verify only | Read database, provider, storage, and route state without mutation. | +| Publish | Change only reviewed publication fields after an explicit write request. | +| Narrow patch | Change one allowlisted concern such as cover, article content, attachment, or media replacement. | + +Reject contradictory modes and publication without an expected-state assertion. + +## Ownership and recovery + +- Give each course import a stable ownership marker. +- Match lessons by stable identity plus title, not array position alone. +- Refuse to overwrite records owned by another workflow. +- Key upload reuse to the exact source checksum. +- Persist provider IDs and resume receipts after each successful step. +- Keep credentials, access tokens, and temporary authentication material out of receipts. + +## Protected state + +Before writing, capture the fields outside the adapter's current allowlist. After writing, compare them byte-for-byte or through normalized values. Narrow article, attachment, cover, or publication modes must not become broad course refreshes. + +## Verification matrix + +| Surface | Required proof | +| --- | --- | +| Local source | File, checksum, bytes, media probe, transcript, and chapters match. | +| Course store | Course and lesson identities, order, access, ownership, and expected state read back. | +| Video host | Asset readiness, duration, visibility, embed policy, and source receipt read back. | +| File storage | Uploaded bytes match the source checksum and intended access policy. | +| Learner route | Catalog, course page, player, access gate, articles, and downloads render correctly. | +| Deployment | The intended public environment serves the verified course version. | + +Treat every unavailable surface as a named limitation, not an inferred success. diff --git a/agent-skills/media/build-video-handbook-articles/SKILL.md b/agent-skills/media/build-video-handbook-articles/SKILL.md new file mode 100644 index 0000000..6cd604c --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/SKILL.md @@ -0,0 +1,68 @@ +--- +name: build-video-handbook-articles +description: Turn verified videos, screen recordings, transcripts, and tutorials into source-faithful visual handbook or blog articles. Use when a recording should become a durable written guide with evidence frames, useful links, provenance, validation, and a scoped local package; do not use for course publication or video editing. +--- + +# Build Video Handbook Articles + +Build one useful article from each verified video. Treat the recording as evidence, not decoration. Let the prose explain decisions while visuals prove the application, process, and result. + +## Operating contract + +- Verify the exact source and creator before writing. +- Prefer a local master and matching transcript when available. +- Keep drafts, media, and commits local unless publication or upload is explicitly requested. +- Do not invent steps, UI states, resources, quotes, outcomes, or URLs. +- Preserve unrelated dirty files and commit only the article package. + +## Supporting tools + +- Read [visual-direction.md](references/visual-direction.md) before selecting or generating visuals. +- Use `scripts/extract_reference_frames.py` for evenly spaced or timestamp-specific frames. +- Use `scripts/validate_handbook_article.py` to check structure, links, images, and optional policy ranges. + +## 1. Establish the article queue + +For each source record creator, first-party URL when applicable, exact video, topic, destination publication, source files, transcript, and status. Use one article per video unless a tightly related series is intentionally combined. + +## 2. Gather evidence + +Probe the master, verify first-party metadata, obtain a transcript, extract frames around major decisions and results, and record every discussed tool or resource. Inspect frames visually; transcript text alone does not prove an interface state. + +```bash +python3 scripts/extract_reference_frames.py /path/to/video.mp4 /path/to/frames --count 10 +``` + +Use `--timestamps 03:40,05:38,09:56` when useful moments are already known. + +## 3. Design the article + +Define the reader and the change the article should create. Use the source's real workflow to choose section count, order, and visual cadence. Do not force a fixed word count or one image after every two sections. + +For every planned visual, name the claim it proves, source frames, recognizable subject, framing device, and clutter to remove. + +## 4. Write from the recording + +- Open with the useful result or tension. +- Follow the demonstrated decisions, not a generic tutorial template. +- Attribute the speaker accurately and use first person only when the destination calls for it. +- Keep claims proportional to the evidence. +- Link first mentions and end with Resources when resources exist. +- Follow the destination's established voice and punctuation rules rather than a personal default. +- Keep publishing copy and upload instructions outside the article. + +## 5. Create the visual system + +Use real source frames when they prove the interface, artifact, process, or result. Crop or compose them for clarity without changing what happened. Generate imagery only for conceptual relationships the recording cannot show, and never replace required UI evidence with invented interface art. + +Every visual needs descriptive alt text and recorded provenance. Inspect it at rendered article size. + +## 6. Validate and deliver + +```bash +python3 scripts/validate_handbook_article.py /path/to/content.md +``` + +Use optional flags when the destination defines section, word-count, image-ratio, or visual-cadence requirements. Then run the repository's focused checks, inspect the rendered article, and commit only the article folder and requested index entry. + +Report the article path, source, extracted and generated visuals, validation, deliberate warnings, publication state, and commit hash. diff --git a/agent-skills/media/build-video-handbook-articles/agents/openai.yaml b/agent-skills/media/build-video-handbook-articles/agents/openai.yaml new file mode 100644 index 0000000..b0b695e --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Build Video Handbook Articles" + short_description: "Turn verified videos into visual guides" + default_prompt: "Use $build-video-handbook-articles to turn this verified video into a source-faithful visual article." diff --git a/agent-skills/media/build-video-handbook-articles/references/visual-direction.md b/agent-skills/media/build-video-handbook-articles/references/visual-direction.md new file mode 100644 index 0000000..04fd72a --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/references/visual-direction.md @@ -0,0 +1,22 @@ +# Visual direction + +## Make each image prove a claim + +Write the article claim at the top of the brief. Choose a visible relationship such as source to result, problem to correction, setup to effect, or workflow to packaged output. + +Prefer frames that show the real application state, artifact, before-and-after pair, process state, or finished result. Avoid presenter overlays, subtitles, unrelated menus, loading states, and empty desktop space. + +## Choose the presentation that explains best + +- **Product close-up:** one clear application, page, or artifact with restrained callouts. +- **Before and after:** matched framing that makes the change obvious. +- **Storyboard:** a hero result plus a short process strip. +- **Technical composition:** the result beside a compact setup or dependency view. +- **Contact sheet:** several experiments or resources on a precise grid. +- **Material metaphor:** an abstract relationship paired with real source evidence. + +Preserve recognizable source details. Do not add features, logos, outcomes, or long readable interface copy that the source never shows. + +## Inspect before accepting + +Check relevance, provenance, legibility, crop, aspect ratio, clutter, hallucinated details, broken text, and contrast at the rendered width. Regenerate only the failed asset. diff --git a/agent-skills/media/build-video-handbook-articles/scripts/extract_reference_frames.py b/agent-skills/media/build-video-handbook-articles/scripts/extract_reference_frames.py new file mode 100755 index 0000000..57bd100 --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/scripts/extract_reference_frames.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Extract clean 16:9 reference frames from a source video.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=True, text=True, capture_output=True) + + +def parse_timestamp(value: str) -> float: + parts = value.strip().split(":") + try: + if len(parts) == 1: + return float(parts[0]) + if len(parts) == 2: + minutes, seconds = parts + return float(minutes) * 60 + float(seconds) + if len(parts) == 3: + hours, minutes, seconds = parts + return float(hours) * 3600 + float(minutes) * 60 + float(seconds) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"Invalid timestamp: {value}") from exc + raise argparse.ArgumentTypeError(f"Invalid timestamp: {value}") + + +def probe_duration(video: Path) -> float: + result = run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(video), + ] + ) + return float(result.stdout.strip()) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("video", type=Path) + parser.add_argument("output_dir", type=Path) + parser.add_argument("--timestamps", help="Comma-separated seconds, MM:SS, or HH:MM:SS") + parser.add_argument("--count", type=int, default=8, help="Evenly spaced frames when timestamps are omitted") + parser.add_argument("--width", type=int, default=1600) + parser.add_argument("--height", type=int, default=900) + args = parser.parse_args() + + if not args.video.is_file(): + parser.error(f"Video does not exist: {args.video}") + if args.count < 1: + parser.error("--count must be at least 1") + for tool in ("ffmpeg", "ffprobe"): + if not shutil.which(tool): + parser.error(f"Required tool is missing: {tool}") + + duration = probe_duration(args.video) + if args.timestamps: + timestamps = [parse_timestamp(value) for value in args.timestamps.split(",")] + else: + step = duration / (args.count + 1) + timestamps = [step * index for index in range(1, args.count + 1)] + + invalid = [value for value in timestamps if value < 0 or value >= duration] + if invalid: + parser.error(f"Timestamps outside the video duration ({duration:.2f}s): {invalid}") + + args.output_dir.mkdir(parents=True, exist_ok=True) + scale = ( + f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease," + f"pad={args.width}:{args.height}:(ow-iw)/2:(oh-ih)/2" + ) + frames = [] + for timestamp in timestamps: + seconds = int(round(timestamp)) + output = args.output_dir / f"source-{seconds:04d}s-{args.width}x{args.height}.jpg" + run( + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-ss", + f"{timestamp:.3f}", + "-i", + str(args.video), + "-frames:v", + "1", + "-vf", + scale, + "-q:v", + "2", + str(output), + ] + ) + frames.append({"timestamp_seconds": round(timestamp, 3), "path": str(output)}) + print(output) + + manifest = { + "source_video": str(args.video.resolve()), + "duration_seconds": round(duration, 3), + "dimensions": f"{args.width}x{args.height}", + "frames": frames, + } + manifest_path = args.output_dir / "frames.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(manifest_path) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as error: + print(error.stderr or str(error), file=sys.stderr) + raise SystemExit(error.returncode) diff --git a/agent-skills/media/build-video-handbook-articles/scripts/test_validate_handbook_article.py b/agent-skills/media/build-video-handbook-articles/scripts/test_validate_handbook_article.py new file mode 100644 index 0000000..4d8ad86 --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/scripts/test_validate_handbook_article.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("validate_handbook_article.py") + + +class HandbookValidatorTests(unittest.TestCase): + def run_validator(self, article: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python3", str(SCRIPT), str(article), *arguments], + check=False, + capture_output=True, + text=True, + ) + + def test_default_does_not_impose_destination_structure(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + article = Path(temporary_dir) / "content.md" + article.write_text( + "# A concise guide\n\n## One useful step\n\nDo the verified thing.\n", + encoding="utf-8", + ) + result = self.run_validator(article) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_destination_policies_are_opt_in(self) -> None: + with tempfile.TemporaryDirectory() as temporary_dir: + article = Path(temporary_dir) / "content.md" + article.write_text( + "# A concise guide\n\n## One useful step\n\nShort copy.\n", encoding="utf-8" + ) + result = self.run_validator( + article, + "--min-sections", + "2", + "--min-words", + "20", + "--require-cover", + "--require-resources", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("minimum is 2", result.stdout) + self.assertIn("final H2 section", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent-skills/media/build-video-handbook-articles/scripts/validate_handbook_article.py b/agent-skills/media/build-video-handbook-articles/scripts/validate_handbook_article.py new file mode 100644 index 0000000..96dbc12 --- /dev/null +++ b/agent-skills/media/build-video-handbook-articles/scripts/validate_handbook_article.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Validate a visual article with optional destination-specific policies.""" + +from __future__ import annotations + +import argparse +import re +import struct +import sys +from pathlib import Path + + +IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)") +LINK_RE = re.compile(r"(? str: + target = raw.strip() + if target.startswith("<") and target.endswith(">"): + target = target[1:-1] + return target.split("#", 1)[0].split("?", 1)[0] + + +def is_remote(target: str) -> bool: + return target.startswith(("http://", "https://", "mailto:", "#")) + + +def is_resources_heading(heading: str) -> bool: + return heading.strip().lower().startswith("resources") + + +def png_size(path: Path) -> tuple[int, int] | None: + with path.open("rb") as handle: + header = handle.read(24) + if header.startswith(b"\x89PNG\r\n\x1a\n") and len(header) >= 24: + return struct.unpack(">II", header[16:24]) + return None + + +def jpeg_size(path: Path) -> tuple[int, int] | None: + with path.open("rb") as handle: + if handle.read(2) != b"\xff\xd8": + return None + while True: + marker_start = handle.read(1) + if not marker_start: + return None + if marker_start != b"\xff": + continue + marker = handle.read(1) + while marker == b"\xff": + marker = handle.read(1) + if marker in (b"\xd8", b"\xd9"): + continue + length_raw = handle.read(2) + if len(length_raw) != 2: + return None + length = struct.unpack(">H", length_raw)[0] + if marker and marker[0] in range(0xC0, 0xC4): + data = handle.read(5) + if len(data) != 5: + return None + height, width = struct.unpack(">HH", data[1:5]) + return width, height + handle.seek(length - 2, 1) + + +def image_size(path: Path) -> tuple[int, int] | None: + return png_size(path) or jpeg_size(path) + + +def parse_ratio(value: str) -> float | None: + if value.lower() == "any": + return None + try: + width, height = value.split(":", 1) + ratio = float(width) / float(height) + except (ValueError, ZeroDivisionError) as error: + raise argparse.ArgumentTypeError("Aspect ratio must be any or WIDTH:HEIGHT") from error + if ratio <= 0: + raise argparse.ArgumentTypeError("Aspect ratio must be positive") + return ratio + + +def add_range_error( + value: int, minimum: int | None, maximum: int | None, label: str, errors: list[str] +) -> None: + if minimum is not None and value < minimum: + errors.append(f"{label} is {value}; minimum is {minimum}.") + if maximum is not None and value > maximum: + errors.append(f"{label} is {value}; maximum is {maximum}.") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("article", type=Path, help="Path to content.md") + parser.add_argument("--min-sections", type=int) + parser.add_argument("--max-sections", type=int) + parser.add_argument("--min-words", type=int) + parser.add_argument("--max-words", type=int) + parser.add_argument( + "--aspect-ratio", type=parse_ratio, default=None, metavar="WIDTH:HEIGHT|any" + ) + parser.add_argument("--aspect-tolerance", type=float, default=0.025) + parser.add_argument("--max-sections-per-image", type=int) + parser.add_argument("--require-cover", action="store_true") + parser.add_argument("--require-resources", action="store_true") + parser.add_argument("--forbid-dashes", action="store_true") + args = parser.parse_args(argv) + for name in ("min_sections", "max_sections", "min_words", "max_words"): + value = getattr(args, name) + if value is not None and value < 0: + parser.error(f"--{name.replace('_', '-')} must be zero or greater") + if args.max_sections_per_image is not None and args.max_sections_per_image < 1: + parser.error("--max-sections-per-image must be at least 1") + if args.aspect_tolerance < 0: + parser.error("--aspect-tolerance must be zero or greater") + return args + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + article = args.article.expanduser().resolve() + if not article.is_file(): + raise ValueError(f"Article does not exist: {article}") + + text = article.read_text(encoding="utf-8") + article_dir = article.parent + errors: list[str] = [] + warnings: list[str] = [] + first_line = next((line for line in text.splitlines() if line.strip()), "") + if not first_line.startswith("# "): + errors.append("The first non-empty line must be an H1 title.") + if args.forbid_dashes and ("—" in text or "–" in text): + errors.append("The article contains an em dash or en dash.") + + sections = list(H2_RE.finditer(text)) + add_range_error(len(sections), args.min_sections, args.max_sections, "H2 section count", errors) + resources_last = bool(sections and is_resources_heading(sections[-1].group(1))) + if args.require_resources and not resources_last: + errors.append("The final H2 section must be Resources or Resources and downloads.") + + images = list(IMAGE_RE.finditer(text)) + first_h2_position = sections[0].start() if sections else len(text) + if args.require_cover and not any(image.start() < first_h2_position for image in images): + errors.append("Add a cover image between the title and first H2 section.") + + content_sections = sections[:-1] if resources_last else sections + if args.max_sections_per_image: + for start in range(0, len(content_sections), args.max_sections_per_image): + group = content_sections[start : start + args.max_sections_per_image] + segment_start = group[0].start() + next_index = start + len(group) + segment_end = ( + content_sections[next_index].start() + if next_index < len(content_sections) + else (sections[-1].start() if resources_last else len(text)) + ) + if not IMAGE_RE.search(text[segment_start:segment_end]): + names = ", ".join(section.group(1) for section in group) + errors.append(f"Missing a visual within the section group: {names}") + + for match in images: + target = clean_target(match.group(1)) + if is_remote(target): + continue + path = (article_dir / target).resolve() + if not path.is_file(): + errors.append(f"Missing image file: {target}") + continue + if args.aspect_ratio is not None: + size = image_size(path) + if size is None: + errors.append(f"Could not read image dimensions: {target}") + continue + width, height = size + if abs(width / height - args.aspect_ratio) > args.aspect_tolerance: + errors.append(f"Image has the wrong aspect ratio: {target} ({width}x{height})") + + for match in LINK_RE.finditer(text): + target = clean_target(match.group(1)) + if is_remote(target) or not target: + continue + if not (article_dir / target).resolve().exists(): + errors.append(f"Missing linked local resource: {target}") + + if resources_last: + resource_text = text[sections[-1].end() :] + without_markdown_links = LINK_RE.sub("", resource_text) + if re.search(r"https?://\S+", without_markdown_links): + errors.append("Resources contains a bare URL instead of clickable Markdown.") + + prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + prose = IMAGE_RE.sub("", prose) + word_count = len(WORD_RE.findall(prose)) + add_range_error(word_count, args.min_words, args.max_words, "Word count", errors) + + print(f"Article: {article}") + print(f"H2 sections: {len(sections)}") + print(f"Images: {len(images)}") + print(f"Words: {word_count}") + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}") + if errors: + print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 + print(f"PASS: 0 errors, {len(warnings)} warning(s)") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error diff --git a/agent-skills/media/build-youtube-companion-runbooks/SKILL.md b/agent-skills/media/build-youtube-companion-runbooks/SKILL.md new file mode 100644 index 0000000..231c238 --- /dev/null +++ b/agent-skills/media/build-youtube-companion-runbooks/SKILL.md @@ -0,0 +1,128 @@ +--- +name: build-youtube-companion-runbooks +description: Create or update source-faithful YouTube companion runbooks from a finished video, local master, transcript, captions, and related article. Use when Codex needs to produce `youtube.md` packaging with accurate titles, hooks, thumbnail directions, chapter timestamps, a YouTube description, social and LinkedIn copy, a pinned comment, public-link gates, resource packaging, and a final upload checklist. +--- + +# Build YouTube companion runbooks + +Turn verified video evidence into a practical `youtube.md` package. Keep internal production notes separate from copy that will be pasted into YouTube or social platforms. + +## Gather the evidence + +1. Locate the final video or local master, transcript or captions, related article, and owned thumbnail assets. +2. Probe the video when its duration or dimensions are unknown. +3. Read the complete article and transcript before writing titles or chapters. +4. Verify any existing public video, article, repository, product, or resource URL from its first-party page. +5. Record missing public URLs as explicit gates. Never substitute a similar video or invent a link. + +Prefer the final uploaded video for timestamps. When only a local master exists, derive chapters from the transcript and require a post-upload recheck. + +## Start from the template + +Copy [assets/youtube-companion-template.md](assets/youtube-companion-template.md) into the target article folder as `youtube.md`. Replace every bracketed instruction with source-backed content or a standardized publication gate. + +Keep these internal source links near the top when available: + +- related article +- source video or local master +- transcript or captions +- optimized upload file + +Use neutral placeholders in reusable skills and public repositories. Do not hard-code a creator's home directory, email, credentials, private project names, or unpublished account details. + +## Choose one honest package + +Lead with the strongest visible result, conflict, or useful surprise from the recording. + +Create: + +- one recommended title with a short reason +- five or six alternate titles +- five opening hooks +- two thumbnail directions +- one short thumbnail phrase, usually two to four words + +Name the product, model, or medium when it improves clarity. Keep the promise inside the evidence. Do not claim “one prompt,” “zero edits,” a cost, a speed, or a result unless the recording proves it. + +Build the primary thumbnail from an owned frame or article visual. Make the finished result larger than the tool interface. Remove tiny UI text that will disappear on a phone. Avoid generic logos, fake code, long prompt text, or imagery unrelated to the video. + +## Build chapters from the transcript + +Create 8 to 20 chapters for a normal long-form video. Use fewer for short videos and more only when the recording has real topic changes. + +Follow these rules: + +- Start with `00:00`. +- Keep timestamps strictly increasing. +- Place a chapter where the subject changes, not at every sentence. +- Use concrete labels that describe what the viewer learns or sees. +- Keep most labels under 65 characters. +- Avoid duplicate or nearly duplicate chapter names. +- Recheck every timestamp after the final upload, intro replacement, or edit. + +Copy the same verified chapter list into the YouTube description. + +## Write the YouTube description + +Use the first two lines for the result and workflow. Explain what the viewer will learn in plain language, then add: + +1. a short list of covered steps +2. an exact prompt only when it appears in the source +3. the verified chapter list +4. a `Resources:` block with first-party URLs + +Use `[ADD YOUTUBE URL BEFORE GOING LIVE]`, `[ADD ARTICLE URL BEFORE GOING LIVE]`, or `[ADD RESOURCE ZIP URL BEFORE GOING LIVE]` when a public destination does not exist yet. + +Keep local filesystem paths out of public copy blocks. Link the first mention of each product, site, repository, model, or download. Do not claim a download exists until the file and public link are verified. + +## Package downloads as one resource + +When the video offers several files, package the article PDF, templates, prompt files, README, and related assets into one ZIP. Upload and share that ZIP through the approved provider, then use one link throughout `youtube.md`. + +Do not scatter several file links across the description, pinned comment, and social posts. If no download bundle exists, omit the claim and leave the upload checklist explicit. + +## Adapt the idea for each channel + +Create these copy blocks from the same source evidence: + +- `Social Share`: concise, first-person, and suitable for X or Threads +- `LinkedIn Post`: explain the workflow and practical lesson with more context +- `Pinned Comment`: repeat the strongest action, link the article or source, and ask one useful question +- `Short Description`: one or two sentences for metadata or previews + +Preserve the creator's spoken vocabulary and opinion. Cut generic setup, fake revelations, repeated conclusions, decorative dashes, and unsupported superlatives. + +## Add publication gates + +End with an upload checklist that covers: + +- public video, article, sponsor, and download URLs +- chapter verification against the uploaded file +- thumbnail export at 1280 by 720 and a phone-size check +- consistent product and model spelling +- first-party resource-link checks +- sponsorship disclosure when applicable +- a reminder to add the final YouTube URL back to the article + +Uploading, publishing, sending sponsor review, and activating social automation are separate external actions. Perform only the actions the user authorized. + +## Validate the runbook + +Run: + +```bash +python3 scripts/validate_youtube_companion.py /absolute/path/to/youtube.md +``` + +Resolve the script relative to this skill folder when invoked outside the skill directory. + +Then check: + +```bash +git diff --check -- /absolute/path/to/youtube.md +git status --short -- /absolute/path/to/article-folder +``` + +Review every reported publication gate. A runbook can be complete with explicit gates, but it is not ready to publish until those gates are replaced with verified public values. + +When working in a mixed worktree, stage and commit only the new skill or companion files. diff --git a/agent-skills/media/build-youtube-companion-runbooks/agents/openai.yaml b/agent-skills/media/build-youtube-companion-runbooks/agents/openai.yaml new file mode 100644 index 0000000..20732db --- /dev/null +++ b/agent-skills/media/build-youtube-companion-runbooks/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Build YouTube Companion Runbooks" + short_description: "Package videos for YouTube and social launch" + default_prompt: "Use $build-youtube-companion-runbooks to create a source-faithful YouTube companion from this video, transcript, and article." diff --git a/agent-skills/media/build-youtube-companion-runbooks/assets/youtube-companion-template.md b/agent-skills/media/build-youtube-companion-runbooks/assets/youtube-companion-template.md new file mode 100644 index 0000000..b00ae13 --- /dev/null +++ b/agent-skills/media/build-youtube-companion-runbooks/assets/youtube-companion-template.md @@ -0,0 +1,142 @@ +# YouTube Companion + +This runbook packages `[VIDEO OR ARTICLE TITLE]` for YouTube and social distribution. + +Article: `[LINK TO CONTENT.MD OR PUBLIC ARTICLE]` + +Source video: `[LOCAL MASTER OR VERIFIED PUBLIC VIDEO]` + +Source captions: `[LINK TO TRANSCRIPT OR CAPTIONS]` + +Public video URL: `[ADD YOUTUBE URL BEFORE GOING LIVE]` + +Public article URL: `[ADD ARTICLE URL BEFORE GOING LIVE]` + +## Recommended Package + +**Title:** `[RECOMMENDED TITLE]` + +**Thumbnail text:** `[TWO TO FOUR WORDS]` + +**Opening promise:** `[ONE SOURCE-BACKED SENTENCE]` + +`[EXPLAIN WHY THIS PACKAGE FITS THE RECORDING AND NAME ANY MISLEADING CLAIM TO AVOID.]` + +## Hook Options + +- `[HOOK 1]` +- `[HOOK 2]` +- `[HOOK 3]` +- `[HOOK 4]` +- `[HOOK 5]` + +## Title Ideas + +1. `[TITLE 1]` +2. `[TITLE 2]` +3. `[TITLE 3]` +4. `[TITLE 4]` +5. `[TITLE 5]` + +## YouTube Title A/B Hooks + +- `[A/B TITLE 1]` +- `[A/B TITLE 2]` +- `[A/B TITLE 3]` +- `[A/B TITLE 4]` +- `[A/B TITLE 5]` + +## Thumbnail Direction + +### Primary + +- `[OWNED BASE FRAME OR ARTICLE VISUAL]` +- Main text: `[SHORT PHRASE]` +- `[SUBJECT, CROP, HIERARCHY, AND COLOR NOTES]` + +### Alternate + +- `[SECOND OWNED FRAME OR COMPOSITION]` +- Main text: `[SHORT PHRASE]` +- `[SUBJECT, CROP, HIERARCHY, AND COLOR NOTES]` + +Avoid `[SPECIFIC MISLEADING OR GENERIC THUMBNAIL CHOICES]`. + +## Chapter Timestamps + +```text +00:00 [OPENING RESULT OR TENSION] +00:30 [FIRST REAL TOPIC CHANGE] +01:20 [NEXT REAL TOPIC CHANGE] +``` + +Recheck these timestamps against the final uploaded file. + +## YouTube Description + +```text +[TWO-LINE RESULT AND WORKFLOW HOOK] + +In this video, I show: + +- [STEP OR LESSON] +- [STEP OR LESSON] +- [STEP OR LESSON] + +Chapters: +00:00 [OPENING RESULT OR TENSION] +00:30 [FIRST REAL TOPIC CHANGE] +01:20 [NEXT REAL TOPIC CHANGE] + +Resources: +Full article: [ADD ARTICLE URL BEFORE GOING LIVE] +[FIRST-PARTY RESOURCE NAME]: [VERIFIED URL] +``` + +## Social Share + +```text +[SHORT FIRST-PERSON HOOK] + +[CONCRETE WORKFLOW OR RESULT] + +Watch: [ADD YOUTUBE URL BEFORE GOING LIVE] +Article: [ADD ARTICLE URL BEFORE GOING LIVE] +``` + +## LinkedIn Post + +```text +[CONCRETE PROBLEM OR RESULT] + +[WORKFLOW WITH SPECIFIC STEPS, DECISIONS, OR EVIDENCE] + +Full video: [ADD YOUTUBE URL BEFORE GOING LIVE] +Article: [ADD ARTICLE URL BEFORE GOING LIVE] +``` + +## Pinned Comment + +```text +[BEST PRACTICAL TAKEAWAY] + +Article: [ADD ARTICLE URL BEFORE GOING LIVE] + +[ONE USEFUL QUESTION FOR VIEWERS] +``` + +## Short Description + +```text +[ONE OR TWO SENTENCES FOR METADATA OR PREVIEWS] +``` + +## Upload Checklist + +- Replace every `[ADD ... BEFORE GOING LIVE]` gate with a verified public URL. +- Recheck chapters against the final upload. +- Export the thumbnail at 1280 by 720 and inspect it at phone size. +- Confirm product, model, creator, and repository spelling. +- Confirm all public resource links open without authentication. +- Add no download claim unless the real bundle and public link exist. +- Add the final YouTube URL back to the related article after upload. diff --git a/agent-skills/media/build-youtube-companion-runbooks/scripts/validate_youtube_companion.py b/agent-skills/media/build-youtube-companion-runbooks/scripts/validate_youtube_companion.py new file mode 100644 index 0000000..d554a1b --- /dev/null +++ b/agent-skills/media/build-youtube-companion-runbooks/scripts/validate_youtube_companion.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Validate the structure and chapter timing of a YouTube companion runbook.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +REQUIRED_HEADINGS = ( + "# YouTube Companion", + "## Recommended Package", + "## Hook Options", + "## Title Ideas", + "## Thumbnail Direction", + "## Chapter Timestamps", + "## YouTube Description", + "## Social Share", + "## LinkedIn Post", + "## Pinned Comment", + "## Short Description", + "## Upload Checklist", +) + +PUBLICATION_GATES = ( + "[ADD YOUTUBE URL BEFORE GOING LIVE]", + "[ADD ARTICLE URL BEFORE GOING LIVE]", + "[ADD RESOURCE ZIP URL BEFORE GOING LIVE]", +) + +CHAPTER_PATTERN = re.compile( + r"^(?P(?:\d+:)?[0-5]?\d:[0-5]\d)\s+(?P