#!/usr/bin/env bash # ============================================================ # lib/doctor-vendored.sh — doctor.sh check for the externally vendored # skills (curl-pinned externals in plugins.lock.json + link.sh's # EXTERNAL_SKILLS array). doctor.sh's "GStack submodule" section only # covers the gstack submodule — this covers the OTHER external skill # packs (emil-design-eng, the agent-skills trio, the five Mengto scroll # skills, and any name link.sh links with no lock entry at all, e.g. # frontend-design, design-motion-principles). # # One entry point, `check_vendored_skills # [profile_file]`, sourced and called by doctor.sh. Two things checked # per name in link.sh's EXTERNAL_SKILLS array: # 1. its file(s) exist under skills-external// — expected file # list comes from the matching plugins.lock.json entry (list shape # -> ["SKILL.md"], dict shape -> its own file list, the # emil-design-eng single-file "path" shape -> the key itself is the # name, file "SKILL.md") or, when no lock entry names it at all, # defaults to ["SKILL.md"]. # 2. when the name is listed in (or no # is passed — the "could not resolve the active profile" case), # the /skills/ symlink points at # /skills-external/. A name absent from the profile is # reported parked, not failed. # # Lock parsing via python3 argv (never string-spliced) — same pattern as # lib/vendor-skills.sh's _vendor_read_lock. link.sh's EXTERNAL_SKILLS # array is parsed with a single-purpose grep/sed, tolerant to it # spanning multiple lines. # # Every name/file pulled from the lock or link.sh is spliced into a # filesystem path (skills-external//, # /skills/): _dv_valid_item_name allowlists it first # (a rejection is a warn + skip, never a fail). doctor.sh's active # profile splices into lib/profiles/.profile the same way, guarded # by _dv_valid_profile_name. # # No `set -euo pipefail` here (mirrors lib/vendor-skills.sh): a sourced # lib must not change the caller's shell options. # ============================================================ # Fallback color helpers when sourced standalone (e.g. the test suite) — # skip anything the caller (doctor.sh) already defines, so doctor.sh's # ERRORS/WARNS counters keep working. if ! declare -F pass >/dev/null 2>&1; then GREEN='\033[0;32m'; NC='\033[0m' pass() { echo -e " ${GREEN}✓${NC} $1"; } fi if ! declare -F fail >/dev/null 2>&1; then RED='\033[0;31m'; NC='\033[0m' fail() { echo -e " ${RED}✗${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 info >/dev/null 2>&1; then BLUE='\033[0;34m'; NC='\033[0m' info() { echo -e " ${BLUE}→${NC} $1"; } fi # _dv_lock_expectations — prints "\t" for every # skill named under a plugins.lock.json entry whose "managed_by" is # "curl": a bare list defaults each name to ["SKILL.md"]; a dict names # its own per-skill file list; an entry with neither (the # emil-design-eng single-file "path" shape) is itself the skill name, # file "SKILL.md" (the literal "path" value is upstream layout, not the # local dest — never used here). Reads the lockfile via argv only. # Every curl-managed entry's shape is validated ("skills" null, a list # of str, or a dict of str -> list of str; "path" a str when present) # BEFORE it is used, so a malformed entry is the same clean failure as # an unreadable file: rc 1, nothing printed. The python3 call's stderr # is discarded — no traceback ever reaches the caller's terminal, only # the rc reaches bash's decision. _dv_lock_expectations() { python3 - "$1" 2>/dev/null <<'PY' import json, sys def valid_skills(skills): """True when "skills" is null, a list of str, or a dict of str -> list of str — the only shapes this lock format allows.""" if skills is None: return True if isinstance(skills, list): return all(isinstance(name, str) for name in skills) if isinstance(skills, dict): return all( isinstance(name, str) and isinstance(files, list) and all(isinstance(f, str) for f in files) for name, files in skills.items() ) return False def skill_files(skills): """Normalize an already-validated "skills" value to {name: [file, ...]} — a bare list defaults to ["SKILL.md"].""" if isinstance(skills, list): return {name: ["SKILL.md"] for name in skills} return skills try: with open(sys.argv[1]) as f: data = json.load(f) except (OSError, ValueError): sys.exit(1) if not isinstance(data, dict): sys.exit(1) for key, entry in data.items(): if not isinstance(entry, dict) or entry.get("managed_by") != "curl": continue skills, path = entry.get("skills"), entry.get("path") if path is not None and not isinstance(path, str): sys.exit(1) if not valid_skills(skills): sys.exit(1) if skills is None: print(f"{key}\tSKILL.md") continue for name, files in skill_files(skills).items(): for file in files: print(f"{name}\t{file}") PY } # _dv_link_names — prints one name per line from link.sh's # EXTERNAL_SKILLS=(...) array, tolerant to it spanning multiple lines. # rc 1 (nothing printed) when the array marker is absent from the file. _dv_link_names() { local link_sh="$1" grep -qF 'EXTERNAL_SKILLS=(' "$link_sh" 2>/dev/null || return 1 awk '/EXTERNAL_SKILLS=\(/{f=1} f{print} f&&/\)/{exit}' "$link_sh" \ | sed -e 's/^.*EXTERNAL_SKILLS=(//' -e 's/).*$//' \ | tr -s '[:space:]' '\n' \ | grep -v '^$' } # _dv_profile_has — true when a line's FIRST # whitespace-separated token equals (the profile line's label # column — comments and the type column are ignored). _dv_profile_has() { local profile_file="$1" name="$2" awk -v n="$name" '$1 == n { found=1 } END { exit !found }' "$profile_file" } # _dv_valid_profile_name — true when matches the # profile-name allowlist (letters, digits, underscore, hyphen only). # is spliced into "lib/profiles/.profile" by doctor.sh, so # a path-traversal or separator character must never reach it. _dv_valid_profile_name() { [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]] } # _dv_valid_item_name — true when (a skill name from # link.sh's EXTERNAL_SKILLS array, or a relative file named by a # plugins.lock.json entry) matches the item-name allowlist (letters, # digits, dot, underscore, hyphen, slash), has no leading "/" and no # ".." path segment. is spliced into a filesystem path under # skills-external/ or /skills/. _dv_valid_item_name() { local name="$1" [[ "$name" =~ ^[A-Za-z0-9._/-]+$ ]] || return 1 case "$name" in /*) return 1 ;; esac case "/$name/" in */../*) return 1 ;; esac } # _dv_check_files — every file (the # "\t" lines from _dv_lock_expectations) names for , # defaulting to just "SKILL.md" when names it no file at all # (a link.sh-only name with no lock entry). fail per missing file. Each # is checked against the item-name allowlist before it is spliced # into a path — a rejected one is warned and skipped, not failed. rc 0 # only when every expected (and allowlisted) file is present. _dv_check_files() { local repo="$1" name="$2" lock_out="$3" local files rel dest all_ok=1 files="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' <<<"$lock_out")" [ -n "$files" ] || files="SKILL.md" while IFS= read -r rel; do [ -n "$rel" ] || continue if ! _dv_valid_item_name "$rel"; then warn "$name: lock file entry \"$rel\" rejected by the item-name \ allowlist — skipped" continue fi dest="$repo/skills-external/$name/$rel" if [ ! -f "$dest" ]; then fail "$name: skills-external/$name/$rel missing — run: make plugin" all_ok=0 fi done <<< "$files" [ "$all_ok" -eq 1 ] } # _dv_check_link — when # is non-empty and does not list , reports it # parked (info), not failed. Otherwise (listed, or no was # passed — active profile could not be resolved, every external is then # expected linked) checks the /skills/ symlink points # at /skills-external/. _dv_check_link() { local claude_home="$1" repo="$2" name="$3" profile_file="$4" local link target label if [ -n "$profile_file" ] && ! _dv_profile_has "$profile_file" "$name"; then label="$(basename "$profile_file" .profile)" info "$name: parked by profile $label" return fi link="$claude_home/skills/$name" target="$repo/skills-external/$name" if [ -L "$link" ] && [ "$(readlink "$link")" = "$target" ]; then pass "$name: vendored + linked" else fail "$name: symlink missing/wrong — run: make link (or: bash \ lib/profile.sh apply )" fi } # check_vendored_skills [profile_file] — see the # file header. Either the lock or link.sh being unreadable (or a # malformed lock entry — _dv_lock_expectations rc 1) is a warn, never a # fail; link.sh unreadable skips the whole check (there is nothing to # iterate). Each from link.sh is checked against the item-name # allowlist before it is spliced into a path — a rejected one is # warned and skipped, not failed. check_vendored_skills() { local repo="$1" claude_home="$2" profile_file="${3:-}" local lock_out names name if ! lock_out="$(_dv_lock_expectations "$repo/plugins.lock.json")"; then warn "plugins.lock.json unreadable or malformed (missing, invalid \ JSON, or an entry with a bad \"skills\"/\"path\" shape) — \ vendored-skills file check falls back to SKILL.md-only defaults" lock_out="" fi if ! names="$(_dv_link_names "$repo/link.sh")" || [ -z "$names" ]; then warn "link.sh EXTERNAL_SKILLS array unreadable — vendored-skills \ check skipped" return 0 fi while IFS= read -r name; do [ -n "$name" ] || continue if ! _dv_valid_item_name "$name"; then warn "link.sh EXTERNAL_SKILLS entry \"$name\" rejected by the \ item-name allowlist — skipped" continue fi _dv_check_files "$repo" "$name" "$lock_out" \ && _dv_check_link "$claude_home" "$repo" "$name" "$profile_file" done <<< "$names" }