Merge feature/doctor-vendored-skills into develop

This commit is contained in:
bastien
2026-09-28 04:14:27 +02:00
9 changed files with 579 additions and 1 deletions
+1
View File
@@ -1308,3 +1308,4 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
- **Caveats**: vendored prompts change upstream with no diff, pin = review point; GSAP-first content vs [[BDR-005]] `motion` default, site-motion states the allowance; LOW hardening open: `re.match` `$` accepts a trailing newline, `source`/`path`/`sha` lock fields not charset-checked; `make link` + `bash lib/profile.sh apply full` after merge (user); sub-agent leftovers `/tmp/mengto-verify` (user removes). - **Caveats**: vendored prompts change upstream with no diff, pin = review point; GSAP-first content vs [[BDR-005]] `motion` default, site-motion states the allowance; LOW hardening open: `re.match` `$` accepts a trailing newline, `source`/`path`/`sha` lock fields not charset-checked; `make link` + `bash lib/profile.sh apply full` after merge (user); sub-agent leftovers `/tmp/mengto-verify` (user removes).
- **Reference**: commits 2a1ad17 (helper + vendoring), ba14b5e (site-motion); contracts `.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-0002.md`; gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a. Links [[BDR-103]] [[BDR-102]] [[LRN-141]] [[LRN-174]] [[EVAL-033]]. - **Reference**: commits 2a1ad17 (helper + vendoring), ba14b5e (site-motion); contracts `.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-0002.md`; gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a. Links [[BDR-103]] [[BDR-102]] [[LRN-141]] [[LRN-174]] [[EVAL-033]].
- **Amendment 2026-09-28**: the two LOW caveats closed on user ask (415b44e): `re.fullmatch` guard, `commit`/`source`/`path` validated before URL construction, 4 more hermetic cases (12). Security PASS, verifier CONFORME 9/9. - **Amendment 2026-09-28**: the two LOW caveats closed on user ask (415b44e): `re.fullmatch` guard, `commit`/`source`/`path` validated before URL construction, 4 more hermetic cases (12). Security PASS, verifier CONFORME 9/9.
- **Amendment 2026-09-28 (doctor)**: `make doctor` now checks every vendored external (6394fa7, feature/doctor-vendored-skills): lock files present, symlink per active profile, parked ≠ failed, hints. Lib sourceable for the hermetic suite (11 cases); doctor mirrors `active_profile()` instead of sourcing profile.sh (its main runs on source). Security: lock shape-validated, allowlists on profile/item names. Closes the gap that emil/frontend-design/motion had since their install.
+1
View File
@@ -533,3 +533,4 @@ rules:
- Case 7 (MengTo motion pack), user go "l'hybride" → [[BDR-104]]: `lib/vendor-skills.sh` shared helper (agent-skills moved onto it), 5 scroll skills vendored at a965851 (2a1ad17), `skills/site-motion` personal skill + routing (ba14b5e). Two analyzers read 22 skills first; 17 skipped (bugs, duplicates, covered, Codex/Xcode machinery). Gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, update-all refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a, shellcheck clean. [[LRN-174]] [[EVAL-033]]. feature/mengto-site-motion UNMERGED. Open for the user: `make link` + `bash lib/profile.sh apply full`, `rm -rf /tmp/mengto-verify`, LOW hardening (regex trailing newline, source/path/sha charset). - Case 7 (MengTo motion pack), user go "l'hybride" → [[BDR-104]]: `lib/vendor-skills.sh` shared helper (agent-skills moved onto it), 5 scroll skills vendored at a965851 (2a1ad17), `skills/site-motion` personal skill + routing (ba14b5e). Two analyzers read 22 skills first; 17 skipped (bugs, duplicates, covered, Codex/Xcode machinery). Gates MET, verifiers CONFORME after 3 re-dispatches (frontmatter shape, update-all refresh convention, security env override + traversal), security PASS ×2, `make test` 36 suites green minus 2 pre-existing T16a, shellcheck clean. [[LRN-174]] [[EVAL-033]]. feature/mengto-site-motion UNMERGED. Open for the user: `make link` + `bash lib/profile.sh apply full`, `rm -rf /tmp/mengto-verify`, LOW hardening (regex trailing newline, source/path/sha charset).
- User: "fais les deux low, et après on merge". Hardening by fresh executor (415b44e): fullmatch guard + lock field validation, 12-case suite; verifier CONFORME 9/9, security PASS 0 findings, full make test 36 suites green minus 2 pre-existing T16a. Merge of feature/mengto-site-motion into develop follows. - User: "fais les deux low, et après on merge". Hardening by fresh executor (415b44e): fullmatch guard + lock field validation, 12-case suite; verifier CONFORME 9/9, security PASS 0 findings, full make test 36 suites green minus 2 pre-existing T16a. Merge of feature/mengto-site-motion into develop follows.
- User go: feature/mengto-site-motion merged into develop via `gitflow finish` → d3633db, no conflict (develop had not moved), pushed, local + origin copies removed by the lib. develop == origin/develop, no working branch anywhere. The whole review is on develop: cases 1-5 (yesterday) + case 7 (today). Open for the user: `make link` + `bash lib/profile.sh apply full` (8 vendored externals to symlink), `rm -rf /tmp/mengto-verify /tmp/tmp.AAyJzvufO6`. - User go: feature/mengto-site-motion merged into develop via `gitflow finish` → d3633db, no conflict (develop had not moved), pushed, local + origin copies removed by the lib. develop == origin/develop, no working branch anywhere. The whole review is on develop: cases 1-5 (yesterday) + case 7 (today). Open for the user: `make link` + `bash lib/profile.sh apply full` (8 vendored externals to symlink), `rm -rf /tmp/mengto-verify /tmp/tmp.AAyJzvufO6`.
- User: "tout cela s'installe et se met à jour comme le reste ?" → traced: install/plugin/update/link all cover the 8 vendored skills; only `make doctor` was blind to curl-vendored externals (since emil). User go → /feat by hand: `lib/doctor-vendored.sh` + doctor section + README (6394fa7); gates MET, verifier CONFORME ×2, security PASS ×2 after one re-dispatch (MEDIUM traceback leak on malformed lock, LOW allowlists). 37 suites green minus 2 pre-existing T16a. feature/doctor-vendored-skills UNMERGED — human gate. Live: 129 skills in the census, 11 externals ✓ in doctor.
+11
View File
@@ -1,5 +1,16 @@
# TODO # TODO
## 2026-09-28 — make doctor checks the vendored externals (feature/doctor-vendored-skills)
User go "ok ajoute le check doctor" after the install/update/link trace: doctor.sh only
checked the gstack submodule; emil, frontend-design, motion and the 8 curl-vendored
skills were invisible. Contract `.claude/tasks/contracts/2026-09-28-doctor-vendored-*`.
- [x] D1 6394fa7 `lib/doctor-vendored.sh` `check_vendored_skills`: lock expectations (list /
dict / single-path), link.sh EXTERNAL_SKILLS, profile-aware symlink check,
hints `make plugin` / `make link`; doctor.sh section; README line; hermetic suite.
- [x] D2 gates MET, verifier CONFORME ×2, security PASS ×2 (1 re-dispatch: malformed-lock
traceback → warn, allowlists), 37 suites green minus 2 T16a, CHANGELOG, BDR-104
amendment, journal. UNMERGED — human gate.
## 2026-09-27 — case 7: MengTo motion pack → vendor 5 + build site-motion (feature/mengto-site-motion) ## 2026-09-27 — case 7: MengTo motion pack → vendor 5 + build site-motion (feature/mengto-site-motion)
User go "ok pour 1, l'hybride" after two analyzers read 22 skills. Contracts under User go "ok pour 1, l'hybride" after two analyzers read 22 skills. Contracts under
`.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-*`, two feater `.claude/tasks/contracts/2026-09-27-{mengto-vendor,site-motion-skill}-*`, two feater
@@ -0,0 +1,47 @@
# CONTRACT — doctor-vendored
- date: 2026-09-28 | flow: feat (ad-hoc dispatch, /feat gates replayed by the orchestrator) | branch: feature/doctor-vendored-skills
- status: active
## REQUEST (verbatim — IMMUTABLE)
> Add a `make doctor` check for the externally vendored skills, which doctor.sh ignores today (it only checks the gstack submodule). New `lib/doctor-vendored.sh` exposing `check_vendored_skills <repo> <claude_home> [profile_file]`, sourced and called by doctor.sh in a new "Vendored skills" section right after the gstack section. Expected state: (1) every plugins.lock.json entry with `managed_by: curl` has its files under `skills-external/`: `skills` as a list → `<name>/SKILL.md` each; `skills` as a dict → every listed file; single-file `path` shape (emil-design-eng) → `<key>/SKILL.md`; (2) every name in link.sh's `EXTERNAL_SKILLS` array has `skills-external/<name>/SKILL.md` and, when the name is listed in the active profile file (or when no profile file is given), a symlink `<claude_home>/skills/<name>` → `<repo>/skills-external/<name>`; a name absent from the active profile is reported as parked, not failed. Outcomes use doctor's helpers: `fail` "<name>: <what is missing> — run: make plugin" for files, `fail` "<name>: symlink missing/wrong — run: make link (or: bash lib/profile.sh apply <profile>)" for links, `info` "<name>: parked by profile <p>", `pass` "<name>: vendored + linked" otherwise, `warn` when the lock or link.sh cannot be read. Hermetic suite `lib/tests/doctor-vendored.test.sh`. README's doctor line names the new check. User go 2026-09-28 ("ok ajoute le check doctor").
## CLARIFICATIONS
- The lib defines fallback `pass/fail/warn/info` only when the caller has not (same `declare -F` guard as lib/vendor-skills.sh) so doctor.sh's counters (`ERRORS`, `WARNS`) keep working.
- Lock parsing with python3 via argv (never string-spliced); `EXTERNAL_SKILLS` parsed from link.sh with a single-purpose grep/sed of the array line(s), tolerant to the multi-line array.
- Active profile in doctor.sh: resolve the way lib/profile.sh's `active_profile()` does (read its code; the cache path it reads; no `claude` invocation); pass the profile file path `lib/profiles/<name>.profile` to the check; if it cannot be resolved, call the check without a profile file (every external expected linked).
- A name in the profile counts whatever its label column says (external / personal), match on the first token of the line.
- Functions ≤ 25 logic lines, 80-char lines, ≤ 5 params, ≤ 5 locals.
- Suite: fixture repo under mktemp with a fake lock (list, dict with a references/ file, single-path shapes), a fake link.sh holding an `EXTERNAL_SKILLS=(...)` array, a fake `<claude_home>/skills` dir and a fake profile file; cases print `PASS <NAME>`: ALL_PRESENT, FILE_MISSING, DICT_FILES_COMPLETE (dict entry missing one references file → fail names that file), SYMLINK_MISSING_ACTIVE, SYMLINK_WRONG_TARGET, SYMLINK_PARKED (name absent from the profile → info, no fail), NO_PROFILE_EXPECTS_LINK, LOCK_UNREADABLE (warn, rc 0). `PASS=n FAIL=m` summary.
- doctor.sh is read-only; the executor MAY run `bash doctor.sh` live for the criterion below (it inspects, never writes).
## ACCEPTANCE CRITERIA
1. Lib exists and doctor.sh is wired.
CHECK: [ -f lib/doctor-vendored.sh ] && grep -q '^check_vendored_skills()' lib/doctor-vendored.sh && grep -q 'doctor-vendored.sh' doctor.sh && grep -q 'check_vendored_skills' doctor.sh && echo WIRED
EXPECT: WIRED
EVIDENCE: MET exit=0 marker-found :: WIRED
2. Hermetic suite green with every case.
CHECK: out=$(make test suite=lib/tests/doctor-vendored.test.sh 2>&1); echo "$out" | grep -qE "FAIL=[1-9]" && { echo "$out" | tail -15; exit 1; }; for k in ALL_PRESENT FILE_MISSING DICT_FILES_COMPLETE SYMLINK_MISSING_ACTIVE SYMLINK_WRONG_TARGET SYMLINK_PARKED NO_PROFILE_EXPECTS_LINK LOCK_UNREADABLE; do echo "$out" | grep -q "PASS $k" || { echo "missing PASS $k"; exit 1; }; done; echo SUITE_GREEN
EXPECT: SUITE_GREEN
EVIDENCE: MET exit=0 marker-found :: SUITE_GREEN
3. Live doctor on this machine: the eleven externals pass.
CHECK: out=$(bash doctor.sh 2>&1); ok=1; for s in emil-design-eng frontend-design design-motion-principles 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; do echo "$out" | grep -qE "✓.*\b$s\b" || { echo "no pass line for $s"; ok=0; }; done; [ "$ok" -eq 1 ] && echo LIVE_PASS
EXPECT: LIVE_PASS
EVIDENCE: MET exit=0 marker-found :: LIVE_PASS
4. shellcheck clean.
CHECK: shellcheck doctor.sh lib/doctor-vendored.sh lib/tests/doctor-vendored.test.sh && echo SHELLCHECK_OK
EXPECT: SHELLCHECK_OK
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK_OK
5. README names the check; doctrine citations resolve.
CHECK: grep -qi "vendored" README.md && out=$(make test suite=lib/tests/doctrine-citers.test.sh 2>&1) && ! echo "$out" | grep -qE "FAIL=[1-9]" && echo DOC_OK
EXPECT: DOC_OK
EVIDENCE: MET exit=0 marker-found :: DOC_OK
## FILE SCOPE
- lib/doctor-vendored.sh (new), lib/tests/doctor-vendored.test.sh (new)
- doctor.sh (source line + one section), README.md (the `make doctor` / `bash doctor.sh` description lines)
## PLAN
1. Read doctor.sh (helpers lines 12-15, `check_symlink` 38, the gstack section ~90-118, `check_automode` 258 + its call 310, the summary), lib/vendor-skills.sh (lock read pattern, fallback helpers), lib/profile.sh `active_profile()` + `read_profile()`, link.sh lines 90-105, one hermetic suite for the style.
2. lib/doctor-vendored.sh: `_dv_lock_expectations` (python3 argv → lines `<name>\t<file>`), `_dv_link_names` (parse EXTERNAL_SKILLS), `_dv_profile_has <file> <name>`, `_dv_check_files`, `_dv_check_link`, `check_vendored_skills`.
3. doctor.sh: source the lib; section header in the file's style; resolve the profile file; call the check.
4. README lines; suite; run criteria 1-5.
+11
View File
@@ -7,6 +7,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **`make doctor` checks the vendored externals** — new
`lib/doctor-vendored.sh` (`check_vendored_skills`), wired into doctor.sh
after the gstack section: every curl-pinned entry of plugins.lock.json
has its files under `skills-external/` (list, dict or single-path lock
shapes), every `EXTERNAL_SKILLS` name of link.sh is symlinked into
`~/.claude/skills/` when the active profile lists it, parked names are
reported not failed, hints `make plugin` / `make link`. Lock entries are
shape-validated (a malformed lock yields one warn, never a traceback) and
profile, skill and file names pass an allowlist before becoming paths.
Until now doctor only checked the gstack submodule. Hermetic suite
`lib/tests/doctor-vendored.test.sh`, 11 cases.
- **`skills/site-motion`** — personal skill for site-level motion - **`skills/site-motion`** — personal skill for site-level motion
choreography (scroll engine choice and Lenis/ScrollTrigger sync, Astro choreography (scroll engine choice and Lenis/ScrollTrigger sync, Astro
ClientRouter lifecycle, pin/scrub numbers, sticky stacks, video and image ClientRouter lifecycle, pin/scrub numbers, sticky stacks, video and image
+1 -1
View File
@@ -361,7 +361,7 @@ make profile-reset # go to the default profile (full)
make new-skill name=myskill # scaffold agent + skill files make new-skill name=myskill # scaffold agent + skill files
``` ```
`doctor.sh` checks: symlinks, GStack submodule, Playwright browser cache, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency. `doctor.sh` checks: symlinks, GStack submodule, vendored skills (curl-pinned externals in `plugins.lock.json` + `link.sh`'s `EXTERNAL_SKILLS`, per the active profile), Playwright browser cache, prerequisites (git, Node, Cargo, Python, Claude Code), plugins, permissions, token budget, config consistency.
--- ---
+34
View File
@@ -22,6 +22,8 @@ VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown")
source "$REPO/lib/detect-plugins.sh" source "$REPO/lib/detect-plugins.sh"
# shellcheck source=lib/gstack-playwright.sh disable=SC1091 # shellcheck source=lib/gstack-playwright.sh disable=SC1091
source "$REPO/lib/gstack-playwright.sh" source "$REPO/lib/gstack-playwright.sh"
# shellcheck source=lib/doctor-vendored.sh disable=SC1091
source "$REPO/lib/doctor-vendored.sh"
echo "" echo ""
echo "═══ claude-config doctor (v${VERSION}) ═══" echo "═══ claude-config doctor (v${VERSION}) ═══"
@@ -117,6 +119,38 @@ fi
echo "" echo ""
# ────────────────────────────────────────────────────────────
# 2b. Vendored skills (curl-pinned externals: plugins.lock.json's
# managed_by:curl entries + link.sh's EXTERNAL_SKILLS array — the OTHER
# externals the GStack section above does not cover)
# ────────────────────────────────────────────────────────────
echo "── Vendored skills ──"
# Mirrors lib/profile.sh's active_profile() (read_cache + the
# blank/"none" -> DEFAULT_PROFILE fallback) without sourcing profile.sh
# itself (its main() would run unconditionally) and without ever
# invoking `claude`.
_dv_active_profile=$(head -n1 "$REPO/.active-profile" 2>/dev/null \
| tr -d '[:space:]')
[ -z "$_dv_active_profile" ] && _dv_active_profile="none"
[ "$_dv_active_profile" = "none" ] && _dv_active_profile="full"
# .active-profile's value is spliced into a lib/profiles/ path below —
# reject anything outside the profile-name allowlist before that splice.
if ! _dv_valid_profile_name "$_dv_active_profile"; then
warn ".active-profile has invalid value \"$_dv_active_profile\" — \
falling back to profile full"
_dv_active_profile="full"
fi
_dv_profile_file="$REPO/lib/profiles/$_dv_active_profile.profile"
if [ -f "$_dv_profile_file" ]; then
check_vendored_skills "$REPO" "$HOME/.claude" "$_dv_profile_file"
else
# Active profile unresolved — every external is expected linked.
check_vendored_skills "$REPO" "$HOME/.claude"
fi
unset _dv_active_profile _dv_profile_file
echo ""
# ── Playwright browsers (read-only report; NOT nested under gstack — 2 of # ── Playwright browsers (read-only report; NOT nested under gstack — 2 of
# the 3 registered installs are gsd-pi, not gstack) ── # the 3 registered installs are gsd-pi, not gstack) ──
echo "── Playwright browsers ──" echo "── Playwright browsers ──"
+257
View File
@@ -0,0 +1,257 @@
#!/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 <repo> <claude_home>
# [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/<name>/ — 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 <profile_file> (or no <profile_file>
# is passed — the "could not resolve the active profile" case),
# the <claude_home>/skills/<name> symlink points at
# <repo>/skills-external/<name>. 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/<name>/<file>,
# <claude_home>/skills/<name>): _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/<name>.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 <lockfile> — prints "<name>\t<file>" 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 <link_sh> — 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 <profile_file> <name> — true when a line's FIRST
# whitespace-separated token equals <name> (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 <name> — true when <name> matches the
# profile-name allowlist (letters, digits, underscore, hyphen only).
# <name> is spliced into "lib/profiles/<name>.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 <name> — true when <name> (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. <name> is spliced into a filesystem path under
# skills-external/ or <claude_home>/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 <repo> <name> <lock_out> — every file <lock_out> (the
# "<name>\t<file>" lines from _dv_lock_expectations) names for <name>,
# defaulting to just "SKILL.md" when <lock_out> names it no file at all
# (a link.sh-only name with no lock entry). fail per missing file. Each
# <rel> 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 <claude_home> <repo> <name> <profile_file> — when
# <profile_file> is non-empty and does not list <name>, reports it
# parked (info), not failed. Otherwise (listed, or no <profile_file> was
# passed — active profile could not be resolved, every external is then
# expected linked) checks the <claude_home>/skills/<name> symlink points
# at <repo>/skills-external/<name>.
_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 <profile>)"
fi
}
# check_vendored_skills <repo> <claude_home> [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 <name> 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"
}
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# lib/tests/doctor-vendored.test.sh — lib/doctor-vendored.sh's
# check_vendored_skills(): a fixture repo under mktemp with a fake
# plugins.lock.json (single-path shape, list shape, dict shape with a
# references/ file), a fake link.sh holding a multi-line
# EXTERNAL_SKILLS=(...) array, a fake <claude_home>/skills dir and a fake
# profile file. Cases: every file present + linked (ALL_PRESENT), a
# list-shape skill missing its SKILL.md (FILE_MISSING), a dict-shape
# skill missing one of two files — only that file is named
# (DICT_FILES_COMPLETE), a profile-listed name with no symlink
# (SYMLINK_MISSING_ACTIVE) or a symlink to the wrong target
# (SYMLINK_WRONG_TARGET), a name absent from the profile reported parked
# rather than failed (SYMLINK_PARKED), the same name treated as
# expected-linked (fail, not parked) when no profile file is passed at
# all (NO_PROFILE_EXPECTS_LINK), an unreadable lock file degrading to a
# warn instead of a fail (LOCK_UNREADABLE, rc 0), a lock entry whose
# "skills" is neither null/list/dict degrading the same way with no
# Python traceback leaking (LOCK_MALFORMED_ENTRY, rc 0), the
# profile-name allowlist rejecting a path-traversal value
# (REJECTS_BAD_PROFILE_NAME), and the item-name allowlist rejecting a
# link.sh entry with a ".." segment — warned and skipped, not failed
# (REJECTS_BAD_NAME).
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LIB="$ROOT/lib/doctor-vendored.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
REPO="$WORK/repo"
CLAUDE_HOME="$WORK/claude_home"
mkdir -p "$REPO/skills-external" "$CLAUDE_HOME/skills"
# ── Lock: "ok-skill" mirrors the emil-design-eng single-file "path"
# shape (the key IS the name); "list-entry" mirrors the agent-skills
# bare-list shape (5 names, each defaults to SKILL.md); "dict-entry"
# mirrors the mengto-skills explicit shape (dict-skill needs SKILL.md +
# references/notes.md, and only the latter is ever missing below).
cat > "$REPO/plugins.lock.json" <<'JSON'
{
"ok-skill": {
"path": "skills/ok-skill/SKILL.md",
"managed_by": "curl"
},
"list-entry": {
"managed_by": "curl",
"skills": [
"missing-skill", "active-nolink-skill", "active-wronglink-skill",
"parked-skill", "noprofile-skill"
]
},
"dict-entry": {
"managed_by": "curl",
"skills": {"dict-skill": ["SKILL.md", "references/notes.md"]}
}
}
JSON
# ── link.sh: a multi-line EXTERNAL_SKILLS array, tolerance-tested.
cat > "$REPO/link.sh" <<'SH'
#!/usr/bin/env bash
EXTERNAL_SKILLS=(ok-skill missing-skill dict-skill
active-nolink-skill active-wronglink-skill
parked-skill noprofile-skill)
SH
# ── skills-external/ tree: every name's SKILL.md present, except
# missing-skill (nothing at all) and dict-skill's references/notes.md.
for n in ok-skill dict-skill active-nolink-skill active-wronglink-skill \
parked-skill noprofile-skill; do
mkdir -p "$REPO/skills-external/$n"
echo "v1" > "$REPO/skills-external/$n/SKILL.md"
done
# ── claude_home symlinks: ok-skill correct, active-wronglink-skill
# points elsewhere, active-nolink-skill and noprofile-skill have none.
ln -sf "$REPO/skills-external/ok-skill" "$CLAUDE_HOME/skills/ok-skill"
mkdir -p "$WORK/elsewhere"
ln -sf "$WORK/elsewhere" "$CLAUDE_HOME/skills/active-wronglink-skill"
# ── active.profile: lists everything EXCEPT parked-skill and
# noprofile-skill (both proven absent from it).
cat > "$REPO/active.profile" <<'PROF'
# DESC: fixture profile
ok-skill external
missing-skill external
dict-skill external
active-nolink-skill external
active-wronglink-skill external
PROF
# shellcheck source=../doctor-vendored.sh disable=SC1091
source "$LIB"
# ── With the active profile passed ──────────────────────────────────
out1="$(check_vendored_skills "$REPO" "$CLAUDE_HOME" \
"$REPO/active.profile" 2>&1)"
check_bool ALL_PRESENT \
"$(printf '%s' "$out1" | grep -qF 'ok-skill: vendored + linked' \
&& echo 1 || echo 0)"
check_bool FILE_MISSING \
"$(printf '%s' "$out1" | \
grep -qF 'missing-skill: skills-external/missing-skill/SKILL.md missing' \
&& echo 1 || echo 0)"
check_bool DICT_FILES_COMPLETE \
"$(printf '%s' "$out1" | grep -qF \
'dict-skill: skills-external/dict-skill/references/notes.md missing' \
&& ! printf '%s' "$out1" | grep -qF \
'dict-skill: skills-external/dict-skill/SKILL.md missing' \
&& echo 1 || echo 0)"
check_bool SYMLINK_MISSING_ACTIVE \
"$(printf '%s' "$out1" | \
grep -qF 'active-nolink-skill: symlink missing/wrong' && echo 1 || echo 0)"
check_bool SYMLINK_WRONG_TARGET \
"$(printf '%s' "$out1" | \
grep -qF 'active-wronglink-skill: symlink missing/wrong' \
&& echo 1 || echo 0)"
check_bool SYMLINK_PARKED \
"$(printf '%s' "$out1" | grep -qF 'parked-skill: parked by profile active' \
&& ! printf '%s' "$out1" | \
grep -qF 'parked-skill: symlink missing/wrong' \
&& echo 1 || echo 0)"
# ── No profile file passed at all: noprofile-skill (absent from
# active.profile, parked above) must now be treated as expected-linked.
out2="$(check_vendored_skills "$REPO" "$CLAUDE_HOME" 2>&1)"
check_bool NO_PROFILE_EXPECTS_LINK \
"$(printf '%s' "$out2" | \
grep -qF 'noprofile-skill: symlink missing/wrong' && echo 1 || echo 0)"
# ── LOCK_UNREADABLE — a second, minimal fixture with an invalid
# plugins.lock.json: check_vendored_skills degrades to a warn (not a
# fail) and still returns 0.
BROKEN="$WORK/broken"
mkdir -p "$BROKEN/skills-external/solo-skill"
echo "v1" > "$BROKEN/skills-external/solo-skill/SKILL.md"
echo "not valid json" > "$BROKEN/plugins.lock.json"
cat > "$BROKEN/link.sh" <<'SH'
#!/usr/bin/env bash
EXTERNAL_SKILLS=(solo-skill)
SH
out3="$(check_vendored_skills "$BROKEN" "$CLAUDE_HOME" 2>&1)"
rc3=$?
check_bool LOCK_UNREADABLE \
"$([ "$rc3" -eq 0 ] && printf '%s' "$out3" | \
grep -qF 'plugins.lock.json unreadable' && echo 1 || echo 0)"
# ── LOCK_MALFORMED_ENTRY — a valid-JSON lock whose "skills" is a bare
# number (neither null, list nor dict): degrades to the same warn as an
# unreadable lock, rc 0, and no Python traceback text anywhere in the
# captured output.
MALFORMED="$WORK/malformed"
mkdir -p "$MALFORMED/skills-external/bad-entry"
echo "v1" > "$MALFORMED/skills-external/bad-entry/SKILL.md"
cat > "$MALFORMED/plugins.lock.json" <<'JSON'
{
"bad-entry": {
"managed_by": "curl",
"skills": 42
}
}
JSON
cat > "$MALFORMED/link.sh" <<'SH'
#!/usr/bin/env bash
EXTERNAL_SKILLS=(bad-entry)
SH
out4="$(check_vendored_skills "$MALFORMED" "$CLAUDE_HOME" 2>&1)"
rc4=$?
check_bool LOCK_MALFORMED_ENTRY \
"$([ "$rc4" -eq 0 ] \
&& printf '%s' "$out4" | grep -qF 'plugins.lock.json unreadable' \
&& ! printf '%s' "$out4" | grep -qi 'traceback' \
&& ! printf '%s' "$out4" | grep -q 'Error:' \
&& echo 1 || echo 0)"
# ── REJECTS_BAD_PROFILE_NAME — the profile-name allowlist helper
# rejects a path-traversal value and accepts a plain one.
check_bool REJECTS_BAD_PROFILE_NAME \
"$( { _dv_valid_profile_name "full" \
&& ! _dv_valid_profile_name "../x" \
&& ! _dv_valid_profile_name "a/b" \
&& ! _dv_valid_profile_name "a.b"; } && echo 1 || echo 0)"
# ── REJECTS_BAD_NAME — a link.sh EXTERNAL_SKILLS entry with a ".."
# segment: warned and skipped, never reaches _dv_check_files/
# _dv_check_link (no fail line names it), rc 0.
BADNAME="$WORK/badname"
mkdir -p "$BADNAME/skills-external"
echo '{}' > "$BADNAME/plugins.lock.json"
cat > "$BADNAME/link.sh" <<'SH'
#!/usr/bin/env bash
EXTERNAL_SKILLS=(../evil)
SH
out5="$(check_vendored_skills "$BADNAME" "$CLAUDE_HOME" 2>&1)"
rc5=$?
check_bool REJECTS_BAD_NAME \
"$([ "$rc5" -eq 0 ] \
&& printf '%s' "$out5" | \
grep -qF '"../evil" rejected by the item-name allowlist' \
&& ! printf '%s' "$out5" | grep -qF 'skills-external/../evil' \
&& ! printf '%s' "$out5" | grep -qF '../evil: symlink' \
&& echo 1 || echo 0)"
echo "PASS=$pass FAIL=$fail"
[ "$fail" -eq 0 ]