From 2a1ad1797bcd4860cf9846c0ddb6b601ecdef20b Mon Sep 17 00:00:00 2001 From: bastien Date: Mon, 28 Sep 2026 01:40:01 +0200 Subject: [PATCH] feat(lib): vendor-skills helper, five MengTo scroll skills pinned lib/vendor-skills.sh: vendor_pinned_skills [refresh], list or dict lock shapes, lock read via python argv, traversal and charset guard on lock values, VENDOR_BASE_URL honoured only as file:// (hermetic suite), per-file tmp+mv, refresh skips a skill never installed. install-plugins.sh Step 8e and update-all.sh 7.3 call it for agent-skills and mengto-skills. Vendored at a965851: scroll-world-storytelling, build-threejs-scroll-worlds (+5 references), scroll-scrubbed-visual-sequence, scroll-scrubbed-word- reveal, scroll-progress-timeline; text files only. Registered in link.sh, .gitignore, toggle-external, profile.sh and the design/web/web-full/full profiles, which also list site-motion (personal). Suite: 8 cases. --- .gitignore | 17 +++ install-plugins.sh | 74 ++++-------- lib/profile.sh | 10 +- lib/profiles/design.profile | 10 ++ lib/profiles/full.profile | 8 ++ lib/profiles/web-full.profile | 8 ++ lib/profiles/web.profile | 10 ++ lib/tests/vendor-skills.test.sh | 144 ++++++++++++++++++++++ lib/toggle-external.sh | 20 +++- lib/vendor-skills.sh | 205 ++++++++++++++++++++++++++++++++ link.sh | 5 +- plugins.lock.json | 16 ++- update-all.sh | 38 ++---- 13 files changed, 479 insertions(+), 86 deletions(-) create mode 100755 lib/tests/vendor-skills.test.sh create mode 100644 lib/vendor-skills.sh diff --git a/.gitignore b/.gitignore index a8b9bf3..aa05165 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,11 @@ skills/frontend-design skills/ci-cd-and-automation skills/deprecation-and-migration skills/observability-and-instrumentation +skills/scroll-world-storytelling +skills/build-threejs-scroll-worlds +skills/scroll-scrubbed-visual-sequence +skills/scroll-scrubbed-word-reveal +skills/scroll-progress-timeline # Impeccable — NOT a symlink: `impeccable skills install --scope=global` # writes the skill dir (and its ~15 MB engine binary) straight in through the @@ -189,6 +194,18 @@ skills-external/observability-and-instrumentation/ skills-external/deprecation-and-migration/ skills-external/ci-cd-and-automation/ +# Mengto scroll-choreography skills (MengTo/Skills) — machine-owned, +# curl'd at the commit pinned in plugins.lock.json ("mengto-skills" entry) +# by install-plugins.sh Step 8e (when absent) and re-fetched at the SAME +# commit by update-all.sh, through the shared lib/vendor-skills.sh helper. +# Not vendored: this is a pin, not a tracked snapshot — bump the commit +# deliberately to pick up an upstream edit. +skills-external/scroll-world-storytelling/ +skills-external/build-threejs-scroll-worlds/ +skills-external/scroll-scrubbed-visual-sequence/ +skills-external/scroll-scrubbed-word-reveal/ +skills-external/scroll-progress-timeline/ + # 21st.dev skill pack — machine-owned: `21st skills install` output, staged by # install-plugins.sh Step 8.7 (the installer refuses to write through the # ~/.claude/skills symlink, so it runs under a throwaway HOME and the skills diff --git a/install-plugins.sh b/install-plugins.sh index f126ff8..4867d29 100644 --- a/install-plugins.sh +++ b/install-plugins.sh @@ -90,22 +90,6 @@ print(v) fi } -# Read a pinned commit sha from plugins.lock.json (agent-skills style entries -# — no "version", a "commit" field instead). Prints the sha, or "" if the -# entry or the field is absent. -# Usage: pinned_commit "agent-skills" → prints the commit sha or "" -pinned_commit() { - local key="$1" - if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then - python3 -c " -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -print(d.get(sys.argv[2], {}).get('commit', '')) -" "$REPO/plugins.lock.json" "$key" 2>/dev/null || true - fi -} - # ============================================================ # DETECT OS # ============================================================ @@ -915,41 +899,29 @@ else fi echo "" -# ── Step 8e: Agent Skills (addyosmani/agent-skills, pinned commit) ── -# Three dev-lifecycle skills vendored the emil-design-eng way (curl → -# skills-external//SKILL.md, symlinked by link.sh) but COMMIT-pinned -# instead of tracking main: the sha lives in plugins.lock.json ("agent-skills" -# entry), never hardcoded here. -echo "── Step 8e: Agent Skills (addyosmani/agent-skills) ─────────" +# ── Step 8e: Agent Skills (addyosmani/agent-skills) + Mengto scroll +# skills (MengTo/Skills) — both commit-pinned, vendored the emil-design-eng +# way (curl → skills-external//, symlinked by link.sh) through the +# shared lib/vendor-skills.sh helper. Shas/paths/file-lists live in +# plugins.lock.json ("agent-skills" / "mengto-skills" entries), never +# hardcoded here. +echo "── Step 8e: Agent Skills + Mengto scroll skills (pinned commit) ──" echo "" -AGENT_SKILLS_NAMES=(observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) -AGENT_SKILLS_SHA=$(pinned_commit "agent-skills") -if [ -z "$AGENT_SKILLS_SHA" ]; then - err "agent-skills: no commit pinned in plugins.lock.json — add an \"agent-skills\" entry with a \"commit\" field" -else - for _as_skill in "${AGENT_SKILLS_NAMES[@]}"; do - _as_dir="$REPO/skills-external/$_as_skill" - _as_url="https://raw.githubusercontent.com/addyosmani/agent-skills/$AGENT_SKILLS_SHA/skills/$_as_skill/SKILL.md" - mkdir -p "$_as_dir" - if [ -f "$_as_dir/SKILL.md" ]; then - ok "$_as_skill already downloaded" - else - info "Downloading SKILL.md from addyosmani/agent-skills ($_as_skill)..." - if curl -fsSL "$_as_url" -o "$_as_dir/SKILL.md.tmp" \ - && mv "$_as_dir/SKILL.md.tmp" "$_as_dir/SKILL.md"; then - ok "$_as_skill installed" - else - rm -f "$_as_dir/SKILL.md.tmp" - err "$_as_skill download failed — try: curl -fsSL $_as_url -o $_as_dir/SKILL.md" - fi - fi - if [ -L "$HOME/.claude/skills/$_as_skill" ]; then - ok "$_as_skill symlink OK" - else - info "Symlinking $_as_skill — will be created by link.sh" - fi - done -fi +# shellcheck source=lib/vendor-skills.sh disable=SC1091 +source "$REPO/lib/vendor-skills.sh" +EXT_SKILL_NAMES=(observability-and-instrumentation deprecation-and-migration + ci-cd-and-automation scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) +vendor_pinned_skills agent-skills +vendor_pinned_skills mengto-skills +for _ext_skill in "${EXT_SKILL_NAMES[@]}"; do + if [ -L "$HOME/.claude/skills/$_ext_skill" ]; then + ok "$_ext_skill symlink OK" + else + info "Symlinking $_ext_skill — will be created by link.sh" + fi +done echo "" # ============================================================ @@ -1240,6 +1212,7 @@ echo " 🔄 frontend-design — distinctive frontend interfaces, anti-AI- echo " 🔄 impeccable — /impeccable design verbs + 45-rule deterministic detector (npx impeccable detect)" echo " 🔄 design-motion-principles — motion/animation design, 3-designer lens (kylezantos)" echo " 🔄 agent-skills trio — observability-and-instrumentation, deprecation-and-migration, ci-cd-and-automation (curl → symlink, pinned commit)" +echo " 🔄 mengto scroll skills — scroll-world-storytelling, build-threejs-scroll-worlds, scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, scroll-progress-timeline (curl → symlink, pinned commit)" echo " 🔄 darwin-skill — autonomous skill optimizer (npx skills, ~/.agents/skills/)" echo " 🔄 21st skill pack — 21st.dev CLI skills; design ones follow the profile (full by default), publishing ones on demand (toggle: lib/toggle-external.sh enable 21st)" echo "" @@ -1249,6 +1222,7 @@ echo " Emil Design Eng at: ~/.claude/skills/emil-design-eng/ (symlink → skill echo " Frontend Design at: ~/.claude/skills/frontend-design/ (symlink → skills-external)" echo " Design Motion Principles at: ~/.claude/skills/design-motion-principles/ (symlink → skills-external)" echo " Agent Skills trio at: ~/.claude/skills/{observability-and-instrumentation,deprecation-and-migration,ci-cd-and-automation}/ (symlink → skills-external)" +echo " Mengto scroll skills at: ~/.claude/skills/{scroll-world-storytelling,build-threejs-scroll-worlds,scroll-scrubbed-visual-sequence,scroll-scrubbed-word-reveal,scroll-progress-timeline}/ (symlink → skills-external)" echo " npx skills at: ~/.agents/skills/ (symlinked into ~/.claude/skills/)" echo "" echo " → Restart Claude Code — plugins load automatically" diff --git a/lib/profile.sh b/lib/profile.sh index a88292f..5cfa3d1 100755 --- a/lib/profile.sh +++ b/lib/profile.sh @@ -82,6 +82,11 @@ MANAGED_EXTERNALS=( observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling + build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence + scroll-scrubbed-word-reveal + scroll-progress-timeline ) # MCP servers that are toggle-managed by `set`, both ways (enable AND @@ -761,7 +766,10 @@ NOTE: (emil-design-eng, frontend-design, design-motion-principles, impeccable, the five 21st design skills, the agent-skills trio observability-and-instrumentation/deprecation-and-migration/ - ci-cd-and-automation). Anything outside those allowlists stays + ci-cd-and-automation, the five Mengto scroll skills + scroll-world-storytelling/build-threejs-scroll-worlds/ + scroll-scrubbed-visual-sequence/scroll-scrubbed-word-reveal/ + scroll-progress-timeline). Anything outside those allowlists stays advisory — run "claude plugin enable|disable" or "bash lib/toggle-external.sh enable|disable " yourself. EOF diff --git a/lib/profiles/design.profile b/lib/profiles/design.profile index 06c5e61..4354a3f 100644 --- a/lib/profiles/design.profile +++ b/lib/profiles/design.profile @@ -31,6 +31,16 @@ frontend-design external design-motion-principles external impeccable external +# External: Mengto scroll-choreography skills (curl, commit-pinned) +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external + +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # External: 21st.dev pack — CLI-driven (no MCP, no API key). 21st-registry # and 21st-design-sync are publishing flows; installed but left parked. 21st-ui-build external diff --git a/lib/profiles/full.profile b/lib/profiles/full.profile index 942a1d2..62959d6 100644 --- a/lib/profiles/full.profile +++ b/lib/profiles/full.profile @@ -86,6 +86,11 @@ impeccable external observability-and-instrumentation external deprecation-and-migration external ci-cd-and-automation external +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external ui-ux-pro-max plugin@ui-ux-pro-max-skill # pr-review-toolkit REMOVED from full (audit 2026-07-02 #12): heaviest # single plugin cost (~2.2k tokens of agent descriptions/session), useful @@ -99,6 +104,9 @@ ui-ux-pro-max plugin@ui-ux-pro-max-skill 21st-cli-use external 21st-ai external +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # === CLIs (advisory) ================================================= 21st cli ctx7 cli diff --git a/lib/profiles/web-full.profile b/lib/profiles/web-full.profile index 8ee1dbb..18da3ab 100644 --- a/lib/profiles/web-full.profile +++ b/lib/profiles/web-full.profile @@ -49,6 +49,11 @@ emil-design-eng external frontend-design external design-motion-principles external impeccable external +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external 21st-ui-build external 21st-ui-explore external 21st-ui-review external @@ -56,6 +61,9 @@ impeccable external 21st-ai external ui-ux-pro-max plugin@ui-ux-pro-max-skill +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # === CLIs ============================================================ 21st cli ctx7 cli diff --git a/lib/profiles/web.profile b/lib/profiles/web.profile index 718c721..0eb19a0 100644 --- a/lib/profiles/web.profile +++ b/lib/profiles/web.profile @@ -38,6 +38,16 @@ frontend-design external design-motion-principles external impeccable external +# External: Mengto scroll-choreography skills (curl, commit-pinned) +scroll-world-storytelling external +build-threejs-scroll-worlds external +scroll-scrubbed-visual-sequence external +scroll-scrubbed-word-reveal external +scroll-progress-timeline external + +# Personal: motion implementation companion (skills/site-motion) +site-motion personal + # External: 21st.dev pack (publishing flows 21st-registry / -design-sync # stay parked) 21st-ui-build external diff --git a/lib/tests/vendor-skills.test.sh b/lib/tests/vendor-skills.test.sh new file mode 100755 index 0000000..f96f062 --- /dev/null +++ b/lib/tests/vendor-skills.test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# lib/tests/vendor-skills.test.sh — lib/vendor-skills.sh's vendor_pinned_skills(): +# list-shape and dict-shape lock entries, a file:// VENDOR_BASE_URL fixture +# tree (VENDOR_SKILLS_REPO_OVERRIDE points skills-external/ + the lock at a +# throwaway repo), tmp+mv semantics (a missing upstream file leaves no dest +# and no tmp), skip-when-present, refresh overwriting a stale copy, a +# non-file:// VENDOR_BASE_URL override being ignored (warn, default URL), +# and a "../evil" lock file being rejected before any fetch. +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB="$ROOT/lib/vendor-skills.sh" +pass=0; fail=0 + +check_bool() { + local name="$1" ok="$2" + if [ "$ok" = 1 ]; then pass=$((pass+1)); echo "PASS $name" + else fail=$((fail+1)); echo "FAIL $name"; fi +} + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +FIXTURE_REPO="$WORK/repo" +UPSTREAM="$WORK/upstream" +mkdir -p "$FIXTURE_REPO/skills-external" + +# Lock: "list-key" mirrors the agent-skills bare-list shape (file defaults +# to SKILL.md, path defaults to "skills"). "dict-key" mirrors the +# mengto-skills explicit shape (a references/ file, an explicit path). +# "fail-key" names a file that is never placed in $UPSTREAM. "missing-key" +# names a skill never installed locally (no skills-external/ dir), to +# prove refresh skips it instead of installing it. "traversal-key" names +# a well-behaved SKILL.md alongside a "../evil" file, to prove the whole +# key is rejected before either one is fetched. +cat > "$FIXTURE_REPO/plugins.lock.json" <<'JSON' +{ + "list-key": { + "source": "https://github.com/acme/list-repo", + "commit": "abc123", + "skills": ["skill-list-a"] + }, + "dict-key": { + "source": "https://github.com/acme/dict-repo", + "commit": "def456", + "path": "somewhere/nested", + "skills": {"skill-dict-a": ["SKILL.md", "references/notes.md"]} + }, + "fail-key": { + "source": "https://github.com/acme/fail-repo", + "commit": "789fail", + "skills": ["skill-fail-a"] + }, + "missing-key": { + "source": "https://github.com/acme/missing-repo", + "commit": "111missing", + "skills": ["skill-missing-a"] + }, + "traversal-key": { + "source": "https://github.com/acme/traversal-repo", + "commit": "222trav", + "skills": {"skill-trav-a": ["SKILL.md", "../evil"]} + } +} +JSON + +mkdir -p "$UPSTREAM/abc123/skills/skill-list-a" +echo v1 > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" +mkdir -p "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references" +echo "dict skill" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/SKILL.md" +echo "dict notes" > "$UPSTREAM/def456/somewhere/nested/skill-dict-a/references/notes.md" +# fail-key: 789fail/skills/skill-fail-a/SKILL.md deliberately absent. +# missing-key: no $UPSTREAM tree at all — refresh must skip it on the +# missing skills-external/ dir alone, before ever reaching curl. +# traversal-key: no $UPSTREAM tree either — the "../evil" file must be +# rejected by the lock reader itself, before any URL is built. + +export VENDOR_SKILLS_REPO_OVERRIDE="$FIXTURE_REPO" +export VENDOR_BASE_URL="file://$UPSTREAM" +# shellcheck source=../vendor-skills.sh disable=SC1091 +source "$LIB" + +# ── LIST_SHAPE ──────────────────────────────────────────────────────────── +vendor_pinned_skills list-key >/dev/null 2>&1 +dest="$FIXTURE_REPO/skills-external/skill-list-a/SKILL.md" +check_bool LIST_SHAPE \ + "$([ -f "$dest" ] && [ "$(cat "$dest")" = v1 ] && echo 1 || echo 0)" + +# ── DICT_SHAPE ──────────────────────────────────────────────────────────── +vendor_pinned_skills dict-key >/dev/null 2>&1 +d1="$FIXTURE_REPO/skills-external/skill-dict-a/SKILL.md" +d2="$FIXTURE_REPO/skills-external/skill-dict-a/references/notes.md" +check_bool DICT_SHAPE \ + "$([ -f "$d1" ] && [ "$(cat "$d2")" = "dict notes" ] && echo 1 || echo 0)" + +# ── FAIL_LEAVES_NOTHING ─────────────────────────────────────────────────── +out="$(vendor_pinned_skills fail-key 2>&1)" +fdest="$FIXTURE_REPO/skills-external/skill-fail-a/SKILL.md" +check_bool FAIL_LEAVES_NOTHING "$([ ! -e "$fdest" ] && [ ! -e "$fdest.tmp" ] \ + && printf '%s' "$out" | grep -q 'not all files landed' && echo 1 || echo 0)" + +# ── SKIP_PRESENT — upstream changes, a plain re-run keeps the old copy ─── +echo v2-upstream-changed > "$UPSTREAM/abc123/skills/skill-list-a/SKILL.md" +vendor_pinned_skills list-key >/dev/null 2>&1 +check_bool SKIP_PRESENT "$([ "$(cat "$dest")" = v1 ] && echo 1 || echo 0)" + +# ── REFRESH_OVERWRITES — same changed upstream, refresh picks it up ───── +vendor_pinned_skills list-key refresh >/dev/null 2>&1 +check_bool REFRESH_OVERWRITES \ + "$([ "$(cat "$dest")" = v2-upstream-changed ] && echo 1 || echo 0)" + +# ── REFRESH_SKIPS_MISSING — refresh never installs a skill that has no +# skills-external/ dir yet; it prints the standard "not installed" +# skip line and never touches curl (no $UPSTREAM/111missing/ exists). +mdest="$FIXTURE_REPO/skills-external/skill-missing-a" +out="$(vendor_pinned_skills missing-key refresh 2>&1)" +check_bool REFRESH_SKIPS_MISSING "$([ ! -e "$mdest" ] \ + && printf '%s' "$out" | \ + grep -qF 'skill-missing-a not installed — skipping (run: make plugin)' \ + && echo 1 || echo 0)" + +# ── OVERRIDE_NON_FILE_IGNORED — a non-file:// VENDOR_BASE_URL is ignored: +# a warn names the variable and the default raw.githubusercontent.com +# prefix is used instead. list-key's SKILL.md is already vendored (v2, +# from REFRESH_OVERWRITES above), so this probe never touches curl +# either way — the assertion is the warn line, and that the file:// mode +# used everywhere else in this suite (asserted by the six cases above +# and below) keeps working. +out="$(VENDOR_BASE_URL="https://evil.example.com" \ + vendor_pinned_skills list-key 2>&1)" +check_bool OVERRIDE_NON_FILE_IGNORED \ + "$(printf '%s' "$out" | grep -q 'VENDOR_BASE_URL ignored' \ + && echo 1 || echo 0)" + +# ── REJECTS_TRAVERSAL — a lock entry naming a "../evil" file is rejected +# whole by the lock reader: nothing is fetched for the key, so not even +# its well-behaved SKILL.md lands, and nothing lands outside the skill's +# own directory either. +out="$(vendor_pinned_skills traversal-key 2>&1)" +rc=$? +tdir="$FIXTURE_REPO/skills-external/skill-trav-a" +outside="$FIXTURE_REPO/skills-external/evil" +check_bool REJECTS_TRAVERSAL "$([ "$rc" -ne 0 ] && [ ! -e "$tdir" ] \ + && [ ! -e "$outside" ] && echo 1 || echo 0)" + +echo "PASS=$pass FAIL=$fail" +[ "$fail" -eq 0 ] diff --git a/lib/toggle-external.sh b/lib/toggle-external.sh index 5c3d4c8..5e4b1ba 100755 --- a/lib/toggle-external.sh +++ b/lib/toggle-external.sh @@ -24,6 +24,9 @@ # observability-and-instrumentation, deprecation-and-migration, # ci-cd-and-automation — the agent-skills trio, same single-symlink shape # as emil-design-eng (commit-pinned instead of main-branch tracking) +# scroll-world-storytelling, build-threejs-scroll-worlds, +# scroll-scrubbed-visual-sequence, scroll-scrubbed-word-reveal, +# scroll-progress-timeline — the Mengto scroll skills, same shape # # For fine-grained activation (only design skills, only qa skills, only # audit skills, etc.) instead of all-or-nothing gstack toggling, use: @@ -44,7 +47,10 @@ err() { echo -e "${RED}✗${NC} $1"; } # All non-plugin tools this script can toggle. MANAGED_TOOLS=(gstack emil-design-eng darwin-skill 21st - observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) + observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) # Prints the skill names that belong to the "21st" pack. Source of truth: # skills-external/21st-* — the `21st skills install` run in install-plugins.sh @@ -80,7 +86,9 @@ status_tool() { done < <(gstack_skills) echo "disabled" ;; - emil-design-eng|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation| \ + scroll-world-storytelling|build-threejs-scroll-worlds|scroll-scrubbed-visual-sequence| \ + scroll-scrubbed-word-reveal|scroll-progress-timeline) [ -d "$REPO/skills-external/$tool" ] || { echo "missing"; return; } [ -e "$SKILLS_DIR/$tool" ] && echo "enabled" || echo "disabled" ;; @@ -119,7 +127,9 @@ disable_tool() { done < <(gstack_skills) ok "gstack disabled ($moved symlinks moved)" ;; - emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration| \ + ci-cd-and-automation|scroll-world-storytelling|build-threejs-scroll-worlds| \ + scroll-scrubbed-visual-sequence|scroll-scrubbed-word-reveal|scroll-progress-timeline) if [ -e "$SKILLS_DIR/$tool" ]; then rm -rf "${DISABLED_DIR:?}/${tool:?}" mv "$SKILLS_DIR/$tool" "$DISABLED_DIR/$tool" @@ -169,7 +179,9 @@ enable_tool() { ok "gstack enabled ($moved symlinks restored)" fi ;; - emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration|ci-cd-and-automation) + emil-design-eng|darwin-skill|observability-and-instrumentation|deprecation-and-migration| \ + ci-cd-and-automation|scroll-world-storytelling|build-threejs-scroll-worlds| \ + scroll-scrubbed-visual-sequence|scroll-scrubbed-word-reveal|scroll-progress-timeline) local src case "$tool" in darwin-skill) src="$HOME/.agents/skills/$tool" ;; diff --git a/lib/vendor-skills.sh b/lib/vendor-skills.sh new file mode 100644 index 0000000..3a6ac47 --- /dev/null +++ b/lib/vendor-skills.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ============================================================ +# lib/vendor-skills.sh — shared curl-vendoring for commit-pinned skills +# +# One helper, `vendor_pinned_skills [refresh]`, replaces the +# inline curl loops install-plugins.sh (Step 8e) and update-all.sh (7.3) +# used to carry separately for the addyosmani/agent-skills trio. Both +# scripts source this file and call it once per plugins.lock.json entry +# ("agent-skills", "mengto-skills", …) — no sha ever hardcoded here. +# +# Lock entry shape (plugins.lock.json): +# "": { +# "source": "https://github.com//", +# "commit": "", +# "path": "", # optional +# "skills": ["", ...] | {"": ["", ...], ...} +# } +# `skills` as a bare list defaults every named skill to `["SKILL.md"]` and +# `path` to "skills" (the agent-skills shape); `skills` as a dict carries an +# explicit per-skill file list (references/*, etc.) and `path` is required. +# +# Raw URL: https://raw.githubusercontent.com///// +# /. VENDOR_BASE_URL overrides the "https://…/" prefix +# (everything before "//…") ONLY when it starts with "file://" (the +# hermetic suite's fixture form); any other non-empty value is ignored — +# a warn names the variable and the default raw.githubusercontent.com +# prefix is used, so a stray value in the caller's environment can never +# silently redirect a real install/update run. +# +# Per file: tmp + mv, tmp removed on failure. A file already at its +# destination is skipped unless refresh="refresh". A skill counts as +# vendored only when every one of its listed files landed; otherwise err +# names the file and the manual curl to retry it. +# +# Every skill name and file path from the lock is rejected — before any +# URL is built or any file fetched — if it contains "..", starts with +# "/", or holds a character outside [A-Za-z0-9._/-]; this guards against +# a lock entry walking a fetch outside skills-external//. +# +# No `set -euo pipefail` here (mirrors lib/detect-plugins.sh): a sourced +# lib must not change the caller's shell options. +# ============================================================ + +VENDOR_REPO="${VENDOR_SKILLS_REPO_OVERRIDE:-$(cd -P \ + "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# Fallback color helpers when sourced standalone (e.g. the test suite) — +# skip anything the caller (install-plugins.sh / update-all.sh) already +# defines, so the same ok/warn/info/err instances keep being used. +if ! declare -F ok >/dev/null 2>&1; then + GREEN='\033[0;32m'; NC='\033[0m' + ok() { echo -e "${GREEN}✓${NC} $1"; } +fi +if ! declare -F info >/dev/null 2>&1; then + BLUE='\033[0;34m'; NC='\033[0m' + info() { echo -e "${BLUE}→${NC} $1"; } +fi +if ! declare -F warn >/dev/null 2>&1; then + YELLOW='\033[1;33m'; NC='\033[0m' + warn() { echo -e "${YELLOW}⚠${NC} $1"; } +fi +if ! declare -F err >/dev/null 2>&1; then + RED='\033[0;31m'; NC='\033[0m' + err() { echo -e "${RED}✗${NC} $1"; } +fi + +# _vendor_read_lock — prints, on +# success: line 1: "" (the raw-file URL up to and including +# , using when non-empty) line 2: "" then one +# "\t …" line per skill (sorted). +# is a plain argv string — the caller (vendor_pinned_skills) already +# checked it is either empty or a "file://" value, never the raw +# VENDOR_BASE_URL. +# rc 1 when the key, its commit or its skills are absent (nothing +# printed), or when a skill name or file path fails the traversal/ +# character check (prints one "INVALID\t" line, nothing +# else — no URL is built and no file is fetched for that lock key). +# Reads the lock path, key and base override via argv — never +# string-spliced into the script. +_vendor_read_lock() { + python3 - "$1" "$2" "$3" <<'PY' +import json, re, sys + +SAFE = re.compile(r'^[A-Za-z0-9._/-]+$') + + +def unsafe(value): + return ".." in value or value.startswith("/") or not SAFE.match(value) + + +lockfile, key, base_override = sys.argv[1], sys.argv[2], sys.argv[3] +with open(lockfile) as f: + data = json.load(f) +entry = data.get(key, {}) +sha = entry.get("commit", "") +owner_repo = entry.get("source", "").rstrip("/").rsplit("github.com/", 1)[-1] +path = entry.get("path", "skills") +skills = entry.get("skills", {}) +if isinstance(skills, list): + skills = {name: ["SKILL.md"] for name in skills} +if not sha or not owner_repo or not skills: + sys.exit(1) +for name, files in skills.items(): + if unsafe(name): + print(f"INVALID\t{name}") + sys.exit(1) + for file in files: + if unsafe(file): + print(f"INVALID\t{file}") + sys.exit(1) +base = base_override or f"https://raw.githubusercontent.com/{owner_repo}" +print(f"{base}/{sha}/{path}") +print(sha) +for name in sorted(skills): + print(f"{name}\t{' '.join(skills[name])}") +PY +} + +# _vendor_fetch_file — tmp + mv; tmp removed on +# failure. Skips an existing dest unless refresh="refresh". rc 0 on +# success or skip, rc 1 (with an err naming the manual curl) on failure. +_vendor_fetch_file() { + local url="$1" dest="$2" refresh="$3" + [ -f "$dest" ] && [ "$refresh" != "refresh" ] && return 0 + mkdir -p "$(dirname "$dest")" + local tmp="$dest.tmp" + if curl -fsSL "$url" -o "$tmp" 2>/dev/null && mv "$tmp" "$dest"; then + return 0 + fi + rm -f "$tmp" + err "$dest download failed — try: curl -fsSL $url -o $dest" + return 1 +} + +# _vendor_install_skill — +# fetches every listed file under //, from +# //. rc 0 only when all of them landed (existing +# or freshly fetched). +_vendor_install_skill() { + local url_base="$1" skill="$2" files="$3" dest_root="$4" refresh="$5" + local file landed=0 total=0 + for file in $files; do + total=$((total + 1)) + _vendor_fetch_file "$url_base/$skill/$file" "$dest_root/$skill/$file" \ + "$refresh" && landed=$((landed + 1)) + done + [ "$landed" -eq "$total" ] +} + +# _vendor_report_lock_error — turns a failed +# _vendor_read_lock into the right err line: a rejected name/path when +# carries the "INVALID\t" marker, the generic +# no-commit-pinned hint otherwise. +_vendor_report_lock_error() { + local lock_key="$1" lock_out="$2" msg + if [[ "$lock_out" == INVALID$'\t'* ]]; then + msg="$lock_key: rejected '${lock_out#INVALID$'\t'}'" + msg="$msg — path traversal or disallowed characters" + err "$msg" + return + fi + local hint="add an entry with a \"commit\" field" + err "$lock_key: no commit/skills pinned in plugins.lock.json — $hint" +} + +# vendor_pinned_skills [refresh] — vendors every skill listed +# under plugins.lock.json's entry into skills-external//. +# Pass "refresh" as the second arg to re-fetch files already present (at +# the same pinned commit — never advances the pin). In refresh mode, a +# skill whose skills-external// directory does not exist yet is +# skipped (the standard "not installed — skipping" info line, no fetch) — +# refresh keeps installed skills current, it never installs a new one; +# install mode (no refresh) still creates it. +vendor_pinned_skills() { + local lock_key="$1" refresh="${2:-}" + local lockfile="$VENDOR_REPO/plugins.lock.json" lock_out lock_rc + local base_override="${VENDOR_BASE_URL:-}" + if [ -n "$base_override" ] && [[ "$base_override" != file://* ]]; then + warn "VENDOR_BASE_URL ignored (must start with file://): $base_override" + base_override="" + fi + lock_out="$(_vendor_read_lock "$lockfile" "$lock_key" "$base_override")" + lock_rc=$? + if [ "$lock_rc" -ne 0 ]; then + _vendor_report_lock_error "$lock_key" "$lock_out" + return 1 + fi + local url_base sha dest_root="$VENDOR_REPO/skills-external" + url_base="$(sed -n '1p' <<<"$lock_out")" + sha="$(sed -n '2p' <<<"$lock_out")" + local skill files + while IFS=$'\t' read -r skill files; do + [ -n "$skill" ] || continue + if [ "$refresh" = "refresh" ] && [ ! -d "$dest_root/$skill" ]; then + info "$skill not installed — skipping (run: make plugin)" + continue + fi + if _vendor_install_skill "$url_base" "$skill" "$files" \ + "$dest_root" "$refresh"; then + ok "$skill vendored (pinned @ ${sha:0:7})" + else + err "$skill: not all files landed (see the download-failed lines above)" + fi + done < <(tail -n +3 <<<"$lock_out") +} diff --git a/link.sh b/link.sh index f9c19d4..470240b 100644 --- a/link.sh +++ b/link.sh @@ -94,7 +94,10 @@ fi # skills/ (and its agents into agents/) at --scope=global, so there is no # skills-external/ copy to symlink. See install-plugins.sh Step 8d. EXTERNAL_SKILLS=(emil-design-eng frontend-design design-motion-principles - observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) + observability-and-instrumentation deprecation-and-migration ci-cd-and-automation + scroll-world-storytelling build-threejs-scroll-worlds + scroll-scrubbed-visual-sequence scroll-scrubbed-word-reveal + scroll-progress-timeline) for _ext_skill in "${EXTERNAL_SKILLS[@]}"; do if [ -d "$REPO/skills-external/$_ext_skill" ]; then if [ -L "$CLAUDE/skills/$_ext_skill" ] && [ "$(readlink "$CLAUDE/skills/$_ext_skill")" = "$REPO/skills-external/$_ext_skill" ]; then diff --git a/plugins.lock.json b/plugins.lock.json index 60d223c..07b34b4 100644 --- a/plugins.lock.json +++ b/plugins.lock.json @@ -48,7 +48,21 @@ "commit": "2686b620fc1fed2e8f60c704839c766b8594c6b6", "skills": ["observability-and-instrumentation", "deprecation-and-migration", "ci-cd-and-automation"], "managed_by": "curl", - "note": "Three dev-lifecycle skills from addyosmani/agent-skills, vendored the emil-design-eng way but COMMIT-pinned (not main-branch tracking): each lands in skills-external//SKILL.md (gitignored, symlinked by link.sh), install-plugins.sh Step 8e curls all three at this commit when absent, update-all.sh re-fetches at the SAME commit on every run (a pin, not an auto-advance). Bump the commit deliberately to pick up upstream changes; the scripts read it from here, never hardcode it." + "note": "Three dev-lifecycle skills from addyosmani/agent-skills, vendored the emil-design-eng way but COMMIT-pinned (not main-branch tracking): each lands in skills-external//SKILL.md (gitignored, symlinked by link.sh), install-plugins.sh Step 8e curls all three at this commit when absent, update-all.sh re-fetches at the SAME commit on every run (a pin, not an auto-advance). Bump the commit deliberately to pick up upstream changes; the scripts read it from here, never hardcode it. Both install-plugins.sh and update-all.sh vendor this entry through lib/vendor-skills.sh's vendor_pinned_skills() — the shared helper also used by the \"mengto-skills\" entry below." + }, + "mengto-skills": { + "source": "https://github.com/MengTo/Skills", + "commit": "a965851e27dc179e693fde1bee94457a64e1a7a5", + "path": "agent-skills/web-design", + "skills": { + "scroll-world-storytelling": ["SKILL.md", "REFERENCES.md"], + "build-threejs-scroll-worlds": ["SKILL.md", "references/kage-anatomy.md", "references/quality-and-qa.md", "references/realtime-architecture.md", "references/scroll-conductor.js", "references/world-bible.md"], + "scroll-scrubbed-visual-sequence": ["SKILL.md", "REFERENCES.md"], + "scroll-scrubbed-word-reveal": ["SKILL.md", "REFERENCES.md"], + "scroll-progress-timeline": ["SKILL.md", "REFERENCES.md"] + }, + "managed_by": "curl", + "note": "Five scroll-choreography skills from MengTo/Skills (agent-skills/web-design), vendored the agent-skills way but with an explicit per-skill file list (SKILL.md + REFERENCES.md, or references/*.md + references/scroll-conductor.js for build-threejs-scroll-worlds) instead of the SKILL.md-only default. Never vendored: demo/, agents/, or any binary asset upstream ships alongside each skill. Text-only, byte-for-byte copies (Codex-isms in the source text stay). Bump the commit deliberately to pick up an upstream edit; install-plugins.sh Step 8e and update-all.sh 7.3 both read it from here via lib/vendor-skills.sh's vendor_pinned_skills(), never hardcoded." }, "impeccable": { "source": "npm:impeccable", diff --git a/update-all.sh b/update-all.sh index f1384fc..03854ac 100644 --- a/update-all.sh +++ b/update-all.sh @@ -379,37 +379,17 @@ else info "design-motion-principles not installed — skipping" fi -# ── 7.3. Update Agent Skills (addyosmani/agent-skills, pinned commit) ── +# ── 7.3. Update Agent Skills + Mengto scroll skills (pinned commit) ── +# Both re-fetched at the SAME pinned commit (never advances the pin) via +# the shared lib/vendor-skills.sh helper — see install-plugins.sh Step 8e. echo "" echo "── Updating Agent Skills (addyosmani/agent-skills)..." -AGENT_SKILLS_SHA="" -if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then - AGENT_SKILLS_SHA=$(python3 -c " -import json, sys -with open(sys.argv[1]) as f: - d = json.load(f) -print(d.get(sys.argv[2], {}).get('commit', '')) -" "$REPO/plugins.lock.json" "agent-skills" 2>/dev/null || true) -fi -AGENT_SKILLS_NAMES=(observability-and-instrumentation deprecation-and-migration ci-cd-and-automation) -if [ -z "$AGENT_SKILLS_SHA" ]; then - warn "agent-skills: no commit pinned in plugins.lock.json — skipping" -else - for _as_skill in "${AGENT_SKILLS_NAMES[@]}"; do - _as_dir="$REPO/skills-external/$_as_skill" - if [ ! -d "$_as_dir" ]; then - info "$_as_skill not installed — skipping (run: make plugin)" - continue - fi - _as_url="https://raw.githubusercontent.com/addyosmani/agent-skills/$AGENT_SKILLS_SHA/skills/$_as_skill/SKILL.md" - if curl -fsSL "$_as_url" -o "$_as_dir/SKILL.md.tmp" \ - && mv "$_as_dir/SKILL.md.tmp" "$_as_dir/SKILL.md"; then - ok "$_as_skill re-fetched at pinned commit" - else - warn "$_as_skill update failed" - fi - done -fi +# shellcheck source=lib/vendor-skills.sh disable=SC1091 +source "$REPO/lib/vendor-skills.sh" +vendor_pinned_skills agent-skills refresh +echo "" +echo "── Updating Mengto scroll skills (MengTo/Skills)..." +vendor_pinned_skills mengto-skills refresh # ── Impeccable (design detector + skill + subagents) ── # Global scope: the installer writes through the ~/.claude/{skills,agents}