diff --git a/dsh/PORT.md b/dsh/PORT.md index 42126fe..5bd92e8 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -81,13 +81,16 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read - **Full installer**: `install.sh` prepares global instructions and skills; the bundle itself is installed by `plugin_manager` because the Desktop profile is Electron-managed. -- **Vendored superpowers**: the install-time source is explicit - (`--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or a clone in - `~/.dsh/superpowers`); a copy inside this repository is rejected because - upstream moves independently (change `dsh-install-source`, D-1). Removing the - legacy `~/.claude` fallbacks is deferred until every machine has rerun - `--skills --roles`; until then both runtime and installer warn when they use - one. +- **Upstream pin (change `dsh-port-v2`, D-2)**: the default install source is + the clone at `~/.dsh/superpowers` pinned to `$DSH_SUPERPOWERS_REF` (default + `v7.0.1`). `install.sh --skills` clones or updates it, runs the vendored + sync + verify, and copies the adapted skills; a broken or empty synced tree + is never installed. Explicit sources (`--superpowers-from`, + `$DSH_SUPERPOWERS_SOURCE`) are synced directly with no network. Upstream + moves independently, so each release is a deliberate pin bump (an issue per + release, D-7). Removing the legacy `~/.claude` fallbacks is deferred until + every machine has rerun `--skills --roles`; until then both runtime and + installer warn when they use one. ## 4. Architecture @@ -211,10 +214,15 @@ real files there; the Claude Code locations are a legacy fallback and the bundle logs one warning per process when it resolves one of them. Install-time source resolution is separate and stricter (change -`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → -`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An -invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a -fresh install and a `WARN` when `~/.dsh/skills` already holds the skills. +`dsh-install-source`, pin in change `dsh-port-v2`): `--superpowers-from` → +`$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at +`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from +`$DSH_SUPERPOWERS_REPO`) → the legacy cache → `~/.claude/skills`. Explicit +sources are synced in place with no network; the pinned clone is fetched or +updated to the ref and reused offline only when already on it. An invalid +explicit source is a hard `FAIL`; a machine offline without a clone is a +`FAIL` with the clone hint, falling back to legacy only when a legacy source +exists. ### OpenSpec skills @@ -243,20 +251,21 @@ openspec archive -y ## 6. Install and verify -Prerequisites: the `claude-harness` checkout, a superpowers source, DSH -0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the -superpowers source once with -`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`, -then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this +Prerequisites: the `claude-harness` checkout, DSH 0.2.1-rc.3, `python3`, +`git`, Node. Claude Code is not required at all: `--skills` clones the pinned +superpowers checkout itself (`DSH_SUPERPOWERS_REF`, default `v7.0.1`) and runs +the DSH sync + verify before installing, so a manual clone is no longer the +first step; `--roles` copies the role files; the hooks come from this checkout. ```bash -# 1. Static checks +# 1. Static checks (offline; reports the configured pin and the source ref) bash dsh/install.sh --check # 2. Global instructions, skills, and roles (run from a terminal; writes -# outside the session workspace). The superpowers source comes from -# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers. +# outside the session workspace). --skills clones the pinned superpowers +# checkout (DSH_SUPERPOWERS_REF, default v7.0.1) and syncs it; or pass +# --superpowers-from / DSH_SUPERPOWERS_SOURCE to sync a checkout directly. bash dsh/install.sh --global bash dsh/install.sh --skills # or: --skills --superpowers-from DIR bash dsh/install.sh --roles diff --git a/dsh/README.md b/dsh/README.md index 3fa3445..fc25630 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -30,20 +30,21 @@ risks. ## Quick start ```bash -# Static check +# Static check (offline; reports the configured pin and the current source) bash dsh/install.sh --check -# Superpowers source: get it once per machine. install.sh never downloads -# anything and no longer depends on ~/.claude. -git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers -# ...or point at any checkout of the superpowers repo / its skills directory: +# Superpowers source. The default is a pinned clone: --skills clones or +# updates ~/.dsh/superpowers at $DSH_SUPERPOWERS_REF (default v7.0.1, fetched +# from $DSH_SUPERPOWERS_REPO), then runs sync + verify and copies the adapted +# skills. Offline it reuses the clone only when it is already on the ref. +bash dsh/install.sh --skills +# ...or sync a checkout directly (no network, no pin enforcement): bash dsh/install.sh --skills --superpowers-from /path/to/superpowers # ...or export DSH_SUPERPOWERS_SOURCE=/path/to/superpowers -# Global instructions, superpowers skills, local role skill, and roles +# Global instructions, local role skill, and roles # (run from a terminal; these write outside the session workspace) bash dsh/install.sh --global -bash dsh/install.sh --skills bash dsh/install.sh --roles # В корне проекта, где уже есть openspec/: добавить agents-скиллы @@ -55,10 +56,15 @@ bash /dsh/install.sh --openspec ``` `--skills` resolves superpowers in this order: `--superpowers-from` → -`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache -(legacy, warns). A clean HOME with no source and no installed copy fails with -the clone command above instead of pretending success; the local `role` and -`sdd` skills are copied either way. +`$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at +`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from +`$DSH_SUPERPOWERS_REPO`; offline it is reused only when already on the ref) → +the Claude Code plugin cache (legacy, warns). Explicit sources are synced in +place with no network. Every sync runs `dsh/sync/sync-from-upstream.mjs` (DSH +adaptations) and `dsh/sync/verify-skills.mjs`; a tree that fails verification +or holds zero skills is never installed. Legacy sources are copied raw. A +clean HOME with no source and no clone fails with the clone command instead of +pretending success; the local `role` and `sdd` skills are copied either way. Do not hand-edit `$DSH_HOME/profiles/desktop/package.json` or `cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin @@ -157,7 +163,8 @@ repository layout: `files`). - `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or - `~/.dsh/superpowers`; Claude Code locations are a **legacy migration + the pinned clone `~/.dsh/superpowers` (`DSH_SUPERPOWERS_REF`, default + `v7.0.1`); Claude Code locations are a **legacy migration fallback** and log one warning per process when used, so a partial Claude Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for another layout. diff --git a/dsh/install.sh b/dsh/install.sh index 989da7f..33f50ff 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -22,6 +22,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUNDLE="$ROOT/dsh/bundle" DSH_HOME="${DSH_HOME:-$HOME/.dsh}" +DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}" +DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}" DO_CHECK=0 DO_GLOBAL=0 DO_SKILLS=0 @@ -29,26 +31,36 @@ DO_ROLES=0 DO_OPENSPEC=0 SUPERPOWERS_FROM="" SUPERPOWERS_FROM_SET=0 -CLONE_HINT="git clone --depth 1 https://github.com/obra/superpowers $DSH_HOME/superpowers" +CLONE_HINT="git clone --depth 1 --branch $DSH_SUPERPOWERS_REF $DSH_SUPERPOWERS_REPO $DSH_HOME/superpowers" +# Every sync writes here; the copy loop and --check read it back. +SUPERPOWERS_OUT="$DSH_HOME/superpowers/skills.synced" usage() { cat < - --check verify python3, git, node, hooks, bundle, skills, and roles + --check verify python3, git, node, hooks, bundle, skills, and roles (offline) --global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md - --skills copy superpowers and local dsh/skills into \$DSH_HOME/skills + --skills sync + verify + copy superpowers and local dsh/skills into \$DSH_HOME/skills --roles copy roles/ into \$DSH_HOME/roles --all --check + --global + --skills + --roles, then print the bundle step --openspec add the agents skill adapter to the current OpenSpec project - --superpowers-from DIR copy superpowers skills from DIR (the repo root or its skills/) + --superpowers-from DIR sync superpowers skills from DIR (the repo root or its skills/) -Superpowers source, first match wins. The script never downloads anything: +Superpowers source, first match wins: 1. --superpowers-from DIR 2. \$DSH_SUPERPOWERS_SOURCE - 3. \$DSH_HOME/superpowers -- get it once: $CLONE_HINT - 4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns) + 3. the pinned clone \$DSH_HOME/superpowers at \$DSH_SUPERPOWERS_REF + (default v7.0.1), cloned or updated from \$DSH_SUPERPOWERS_REPO + (default https://github.com/obra/superpowers); offline it is reused + only when already on the ref + 4. legacy: the Claude Code plugin cache, then ~/.claude/skills + (raw copy, warns; removal is #66) + +Explicit sources are synced in place (no network, no pin enforcement); the +synced tree is verified before anything is copied. Get the default source +once: $CLONE_HINT Bundle installation itself is done in DSH: Plugins -> install a bundle from $BUNDLE @@ -110,39 +122,94 @@ normalize_superpowers_dir() { return 1 } -# Print "\t". Returns 2 when an explicitly -# configured source is invalid (never fall back silently), 1 when none is found. +# The checkout root the sync script consumes: /skills must exist. +# Returns 1 when the skills directory is not part of a checkout layout. +checkout_root() { + local skills_dir="${1:-}" + [ "$(basename "$skills_dir")" = skills ] || return 1 + printf '%s\n' "$(dirname "$skills_dir")" + return 0 +} + +# Resolve the superpowers install source without touching the network. +# Prints "\t\t". Returns 2 when an +# explicitly configured source is invalid (never fall back silently), 1 when +# none is found. superpowers_source() { - local dir legacy + local skills="" checkout="" legacy="" if [ -n "$SUPERPOWERS_FROM" ]; then - dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2 - printf '%s\texplicit\n' "$dir" + skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2 + checkout="$(checkout_root "$skills" 2>/dev/null || true)" + printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills" return 0 fi if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then - dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2 - printf '%s\texplicit\n' "$dir" + skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2 + checkout="$(checkout_root "$skills" 2>/dev/null || true)" + printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills" return 0 fi - if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then - printf '%s\tpinned\n' "$dir" + if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then + printf 'pinned\t%s\t%s\n' "$DSH_HOME/superpowers" "$skills" return 0 fi legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" # A cache directory without using-superpowers is a broken cache, not a source: # accepting it would copy 0 skills and report success (the silent empty # bootstrap this change removes). - if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then - printf '%s\tlegacy\n' "$dir" + if [ -n "$legacy" ] && skills="$(normalize_superpowers_dir "$legacy")"; then + printf 'legacy\t-\t%s\n' "$skills" return 0 fi - if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then - printf '%s\tlegacy\n' "$dir" + if skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then + printf 'legacy\t-\t%s\n' "$skills" return 0 fi return 1 } +# Offline ref report for --check: the synced meta first, then git, then config. +report_checkout_ref() { + local checkout="${1:-}" meta="$SUPERPOWERS_OUT/.sync-meta.json" ref="" version="" tag="" sha="" + if [ -f "$meta" ]; then + ref="$(sed -n 's/^[[:space:]]*"ref":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)" + version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)" + if [ -n "$version" ]; then + echo "ref ${ref:-unknown} (synced, version $version)" + else + echo "ref ${ref:-unknown} (synced)" + fi + return 0 + fi + if [ -n "$checkout" ] && [ -d "$checkout/.git" ]; then + tag="$(git -C "$checkout" describe --tags --exact-match HEAD 2>/dev/null || true)" + if [ -n "$tag" ]; then + echo "ref $tag (git)" + return 0 + fi + sha="$(git -C "$checkout" rev-parse --short HEAD 2>/dev/null || true)" + if [ -n "$sha" ]; then + echo "git $sha (ref $DSH_SUPERPOWERS_REF configured)" + return 0 + fi + fi + echo "ref $DSH_SUPERPOWERS_REF (configured)" +} + +# Ref recorded for an explicit source: the exact tag, else the short sha, +# else "unknown". An explicit source is not pin-enforced, so any of these is +# honest metadata for .sync-meta.json. +source_ref() { + local root="${1:-}" tag="" sha="" + if [ -d "$root/.git" ]; then + tag="$(git -C "$root" describe --tags --exact-match HEAD 2>/dev/null || true)" + [ -n "$tag" ] && { printf '%s\n' "$tag"; return 0; } + sha="$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true)" + [ -n "$sha" ] && { printf '%s\n' "$sha"; return 0; } + fi + printf 'unknown\n' +} + # Human-readable name of the source that made superpowers_source() return 2. explicit_source_name() { if [ -n "$SUPERPOWERS_FROM" ]; then @@ -218,7 +285,10 @@ check() { else echo "WARN: .agents/skills missing; run --openspec in the project" >&2 fi - local src="" rc=0 kind + # --check is offline: report the pin config and whatever source exists, + # never clone or fetch. + echo "ok: superpowers ref ${DSH_SUPERPOWERS_REF} (DSH_SUPERPOWERS_REPO=${DSH_SUPERPOWERS_REPO})" + local src="" rc=0 kind checkout skills_dir src="$(superpowers_source)" || rc=$? if [ "$rc" -eq 2 ]; then report_invalid_explicit_source @@ -230,12 +300,12 @@ check() { echo "WARN: no superpowers source; run: $CLONE_HINT" >&2 fi else - kind="${src##*$'\t'}" - src="${src%%$'\t'*}" + kind="${src%%$'\t'*}"; src="${src#*$'\t'}" + checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}" if [ "$kind" = legacy ]; then - echo "WARN: legacy superpowers source ($src); prefer --superpowers-from or $DSH_HOME/superpowers" >&2 + echo "WARN: legacy superpowers source ($skills_dir); prefer --superpowers-from or $DSH_HOME/superpowers" >&2 else - echo "ok: superpowers source ($src)" + echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout")" fi fi if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then @@ -299,32 +369,85 @@ PY echo "written: $target" } -install_skills() { - mkdir -p "$DSH_HOME/skills" - local line="" rc=0 source kind count=0 - line="$(superpowers_source)" || rc=$? - if [ "$rc" -eq 2 ]; then - report_invalid_explicit_source - return 1 +# The default source: a clone at $DSH_HOME/superpowers pinned to $REF. +# Returns 0 when the clone is usable at the ref, 1 when a fresh clone failed +# (the caller may fall back to legacy), 2 when pre-existing content blocks the +# pin (hard fail: the directory is never touched). +ensure_pinned_clone() { + local dir="$1" ref="$2" + if [ -e "$dir" ] && [ ! -d "$dir" ]; then + echo "FAIL: $dir exists and is not a directory; the pinned clone needs it" >&2 + return 2 fi - if [ "$rc" -ne 0 ]; then - if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then - echo "WARN: no superpowers source found; keeping installed skills in $DSH_HOME/skills" >&2 - echo "WARN: next time run: $CLONE_HINT" >&2 + if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then + if [ ! -d "$dir/.git" ] || ! git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "FAIL: $dir has no usable .git: not a pinned superpowers clone" >&2 + echo "FAIL: move it away and run: $CLONE_HINT" >&2 + return 2 + fi + # Online: refresh the pin. Fetching the tag ref itself and checking out + # FETCH_HEAD handles a moved tag, which `git checkout ` (resolving the + # stale local ref) would miss. + if git -C "$dir" fetch -q --depth 1 origin "refs/tags/$ref" 2>/dev/null || + git -C "$dir" fetch -q --depth 1 origin "$ref" 2>/dev/null; then + if ! git -C "$dir" checkout -q FETCH_HEAD; then + echo "FAIL: checkout of the fetched ref failed in $dir" >&2 + return 2 + fi + echo "ok: superpowers clone at $ref ($dir)" return 0 fi - echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2 - echo "FAIL: get the source once: $CLONE_HINT" >&2 - echo "FAIL: or pass --superpowers-from ; local skills (role, sdd) are installed by this run" >&2 + # Offline: reuse only when HEAD is exactly at the pinned ref. + if git -C "$dir" rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 && + [ "$(git -C "$dir" rev-parse HEAD)" = "$(git -C "$dir" rev-parse "$ref^{commit}")" ]; then + echo "WARN: offline; reusing the existing clone at $ref ($dir)" >&2 + return 0 + fi + echo "FAIL: cannot update $dir to $ref (offline?) and it is not on that ref" >&2 + echo "FAIL: $CLONE_HINT" >&2 + return 2 + fi + # Fresh (absent or empty): clone pinned; a failed clone leaves nothing (RF5). + if ! git clone --depth 1 --branch "$ref" "$DSH_SUPERPOWERS_REPO" "$dir"; then + echo "FAIL: $CLONE_HINT failed" >&2 + rm -rf "$dir" return 1 fi - kind="${line##*$'\t'}" - source="${line%%$'\t'*}" - if [ "$kind" = legacy ]; then - echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2 + echo "ok: cloned superpowers at $ref into $dir" + return 0 +} + +# Sync + verify the checkout into $SUPERPOWERS_OUT. Returns 1 (nothing +# copied) on any problem; the synced tree is the caller's copy source. +sync_skills() { + local checkout="$1" ref="$2" verified="" verified_n="" verify_rc=0 + if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$SUPERPOWERS_OUT" --ref "$ref"; then + echo "FAIL: superpowers sync failed ($checkout)" >&2 + return 1 fi - local dir name dest - for dir in "$source"/*; do + verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$SUPERPOWERS_OUT" 2>&1)" || verify_rc=$? + printf '%s\n' "$verified" + if [ "$verify_rc" -ne 0 ]; then + echo "FAIL: superpowers verify failed; nothing copied" >&2 + return 1 + fi + # T2.5: a verified tree with zero skills (exit 0) must never be installed. + verified_n="$(printf '%s\n' "$verified" | sed -n 's/.*verified \([0-9][0-9]*\) skills.*/\1/p' | tail -n 1)" + case "$verified_n" in + ''|*[!0-9]*) echo "FAIL: verify reported no usable skill count; nothing copied" >&2; return 1 ;; + esac + if [ "$verified_n" -eq 0 ]; then + echo "FAIL: verify reported 0 skills; a tree without skills is never installed" >&2 + return 1 + fi + return 0 +} + +# Copy every /SKILL.md under $1 into $DSH_HOME/skills (rm -rf dest, cp -R). +# The optional label names where the skills came from in the report line. +copy_skills_from() { + local src="$1" label="${2:-$1}" count=0 dir name dest + for dir in "$src"/*; do [ -f "$dir/SKILL.md" ] || continue name="$(basename "$dir")" dest="$DSH_HOME/skills/$name" @@ -332,7 +455,66 @@ install_skills() { cp -R "$dir" "$dest" count=$((count + 1)) done - echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)" + echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)" +} + +install_skills() { + mkdir -p "$DSH_HOME/skills" + + # Explicit source: sync it directly, no network, no pin enforcement. + local explicit_dir="" + if [ -n "$SUPERPOWERS_FROM" ]; then + explicit_dir="$SUPERPOWERS_FROM" + elif [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then + explicit_dir="$DSH_SUPERPOWERS_SOURCE" + fi + if [ -n "$explicit_dir" ]; then + local skills_dir="" checkout="" + skills_dir="$(normalize_superpowers_dir "$explicit_dir")" || { + report_invalid_explicit_source + return 1 + } + checkout="$(checkout_root "$skills_dir")" || { + echo "FAIL: explicit superpowers source must be a checkout root or its skills/ directory: $(explicit_source_name)" >&2 + return 1 + } + if ! sync_skills "$checkout" "$(source_ref "$checkout")"; then + return 1 + fi + copy_skills_from "$SUPERPOWERS_OUT" "$checkout" + return 0 + fi + + # Default source: the pinned clone at $DSH_HOME/superpowers. + local ensure_rc=0 + ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$? + if [ "$ensure_rc" -eq 0 ]; then + if ! sync_skills "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF"; then + return 1 + fi + copy_skills_from "$SUPERPOWERS_OUT" "$DSH_HOME/superpowers" + return 0 + fi + if [ "$ensure_rc" -ge 2 ]; then + return 1 + fi + + # A fresh clone failed (offline): legacy sources stay raw with WARN (D-4). + local legacy="" legacy_skills="" + legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "$legacy" ] && legacy_skills="$(normalize_superpowers_dir "$legacy")"; then + : + elif legacy_skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then + : + else + echo "FAIL: no superpowers source: $CLONE_HINT failed and no legacy source found" >&2 + echo "FAIL: clone it once: $CLONE_HINT" >&2 + echo "FAIL: or pass --superpowers-from ; local skills (role, sdd) are installed by this run" >&2 + return 1 + fi + echo "WARN: legacy superpowers source $legacy_skills; prefer --superpowers-from or $DSH_HOME/superpowers" >&2 + copy_skills_from "$legacy_skills" + return 0 } install_local_skills() { diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index e1623cf..8c6b5ad 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -1,13 +1,15 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { existsSync, lstatSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { cleanup, tempDir, write } from './helpers.mjs' +import { cleanup, git, tempDir, write } from './helpers.mjs' const here = dirname(fileURLToPath(import.meta.url)) const script = join(here, '..', 'install.sh') +const testDir = dirname(fileURLToPath(import.meta.url)) +const fixture = resolve(testDir, 'fixtures', 'superpowers-min') test('install.sh copies skills and roles instead of linking into ~/.claude', () => { const home = tempDir() @@ -26,7 +28,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', () write(join(cache, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') - const env = { ...process.env, HOME: home, DSH_HOME: dshHome } + const env = { ...process.env, HOME: home, DSH_HOME: dshHome, DSH_SUPERPOWERS_REPO: 'file:///nonexistent' } + delete env.DSH_SUPERPOWERS_SOURCE + delete env.DSH_SUPERPOWERS_REF const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' }) const first = run() @@ -90,9 +94,49 @@ function makeLegacyCache(home) { return join(root, 'skills') } +/** A local git repo carrying the reduced upstream checkout on tag v7.0.1. */ +function makePinnedRepo(work) { + const repo = join(work, 'upstream-repo') + cpSync(fixture, repo, { recursive: true }) + try { + git(repo, ['init', '-b', 'main']) + } catch { + git(repo, ['init']) + git(repo, ['checkout', '-b', 'main']) + } + git(repo, ['config', 'user.email', 'dsh-harness@example.invalid']) + git(repo, ['config', 'user.name', 'DSH Harness Test']) + git(repo, ['add', '.']) + git(repo, ['commit', '-m', 'superpowers fixture']) + git(repo, ['tag', 'v7.0.1']) + return repo +} + +/** A full 15-skill checkout copy (explicit sources sync it in place). */ +function fixtureSuperpowers(work, marker = null, root = join(work, 'src', 'superpowers')) { + cpSync(fixture, root, { recursive: true }) + if (marker) { + const skill = join(root, 'skills', 'using-superpowers', 'SKILL.md') + write(skill, `${readFileSync(skill, 'utf8')}\n\n`) + } + return root +} + +function walk(dir, acc = []) { + if (!existsSync(dir)) return acc + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + acc.push(full) + if (entry.isDirectory()) walk(full, acc) + } + return acc +} + function installerEnv(home) { const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') } delete env.DSH_SUPERPOWERS_SOURCE + delete env.DSH_SUPERPOWERS_REPO + delete env.DSH_SUPERPOWERS_REF return env } @@ -103,15 +147,158 @@ function runInstaller(home, args, extraEnv = {}) { }) } -test('--skills takes superpowers from --superpowers-from in a clean HOME', () => { +test('--skills clones the pinned ref, syncs, verifies and installs adapted skills', () => { + const work = tempDir() + try { + const home = join(work, 'home') + const repo = makePinnedRepo(work) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) + const out = run.stdout + run.stderr + assert.equal(run.status, 0, out) + assert.match(run.stdout, /synced \d+ files/) + assert.match(run.stdout, /verified 15 skills/) + assert.match(run.stdout, /copied 15 superpowers skills/) + + const skillFile = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') + const text = readFileSync(skillFile, 'utf8') + assert.match(text, /references\/dsh-tools\.md/) + assert.doesNotMatch(text, /superpowers:/) + assert.ok( + existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'references', 'dsh-tools.md')), + 'the DSH tool mapping must be installed', + ) + assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed') + } finally { + cleanup(work) + } +}) + +test('--skills offline without a source fails with the clone hint', () => { const home = tempDir() try { - const src = makeSuperpowers(join(home, 'src', 'superpowers')) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) + const out = run.stdout + run.stderr + assert.equal(run.status, 1, out) + assert.match(run.stderr, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/) + assert.match(out, /FAIL: no superpowers source/) + assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) + } finally { + cleanup(home) + } +}) + +test('--skills re-run picks up a moved tag', () => { + const work = tempDir() + try { + const home = join(work, 'home') + const repo = makePinnedRepo(work) + const env = { DSH_SUPERPOWERS_REPO: `file://${repo}` } + const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') + + const first = runInstaller(home, ['--skills'], env) + assert.equal(first.status, 0, first.stdout + first.stderr) + assert.doesNotMatch(readFileSync(installed, 'utf8'), /move-marker: second/) + + const upstreamSkill = join(repo, 'skills', 'using-superpowers', 'SKILL.md') + write(upstreamSkill, `${readFileSync(upstreamSkill, 'utf8')}\n\n`) + git(repo, ['add', 'skills/using-superpowers/SKILL.md']) + git(repo, ['commit', '--amend', '--no-edit']) + git(repo, ['tag', '-f', 'v7.0.1']) + + const second = runInstaller(home, ['--skills'], env) + assert.equal(second.status, 0, second.stdout + second.stderr) + assert.match(readFileSync(installed, 'utf8'), /move-marker: second/) + } finally { + cleanup(work) + } +}) + +test('--skills removes a half-cloned dir after a failed fresh clone (RF5)', () => { + const home = tempDir() + try { + const cloneDir = join(home, '.dsh', 'superpowers') + mkdirSync(cloneDir, { recursive: true }) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) + const out = run.stdout + run.stderr + assert.equal(run.status, 1, out) + assert.equal(existsSync(cloneDir), false, 'half-cloned dir must be removed') + } finally { + cleanup(home) + } +}) + +test('--skills reuses an on-ref clone when offline (WARN)', () => { + const work = tempDir() + try { + const home = join(work, 'home') + const repo = makePinnedRepo(work) + + const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) + assert.equal(first.status, 0, first.stdout + first.stderr) + + // Go offline for real: the clone's origin must become unreachable, not + // just the DSH_SUPERPOWERS_REPO env (an existing clone fetches origin). + rmSync(repo, { recursive: true, force: true }) + + const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) + const out = second.stdout + second.stderr + assert.equal(second.status, 0, out) + assert.match(out, /offline; reusing the existing clone/) + assert.match(second.stdout, /copied 15 superpowers skills/) + } finally { + cleanup(work) + } +}) + +test('--skills leaves no staging garbage and records the ref in .sync-meta.json', () => { + const work = tempDir() + try { + const home = join(work, 'home') + const repo = makePinnedRepo(work) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` }) + assert.equal(run.status, 0, run.stdout + run.stderr) + + const cloneDir = join(home, '.dsh', 'superpowers') + const garbage = walk(cloneDir).filter((p) => p.includes('.tmp-')) + assert.deepEqual(garbage, [], `staging garbage left behind: ${garbage.join(', ')}`) + + const meta = JSON.parse(readFileSync(join(cloneDir, 'skills.synced', '.sync-meta.json'), 'utf8')) + assert.equal(meta.ref, 'v7.0.1') + } finally { + cleanup(work) + } +}) + +test('--check is offline and reports the configured ref', () => { + const home = tempDir() + try { + const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) + const out = run.stdout + run.stderr + assert.equal(run.status, 0, out) + assert.match(run.stdout, /ref v7\.0\.1/) + assert.doesNotMatch(out, /fatal:/, '--check must not attempt to clone') + } finally { + cleanup(home) + } +}) + +test('.claude/code-paths lists dsh/', () => { + const codePaths = resolve(testDir, '..', '..', '.claude', 'code-paths') + const text = readFileSync(codePaths, 'utf8') + assert.ok(text.split('\n').includes('dsh/'), 'code-paths must gate dsh/ under require-plan (D-5)') +}) + +test('--skills syncs an explicit --superpowers-from source in a clean HOME', () => { + const home = tempDir() + try { + const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) const run = runInstaller(home, ['--skills', '--superpowers-from', src]) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) - assert.match(run.stdout, /copied 2 superpowers skills/) - assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), out) + assert.match(run.stdout, /synced \d+ files/) + assert.match(run.stdout, /verified 15 skills/) + assert.match(run.stdout, /copied 15 superpowers skills/) + assert.ok(run.stdout.includes(`source: ${src}`), out) assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md'))) assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md'))) @@ -125,10 +312,10 @@ test('--skills takes superpowers from --superpowers-from in a clean HOME', () => test('--superpowers-from accepts the skills directory itself', () => { const home = tempDir() try { - const src = makeSuperpowers(join(home, 'src', 'superpowers')) + const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')]) assert.equal(run.status, 0, run.stdout + run.stderr) - assert.match(run.stdout, /copied 2 superpowers skills/) + assert.match(run.stdout, /copied 15 superpowers skills/) } finally { cleanup(home) } @@ -137,7 +324,7 @@ test('--superpowers-from accepts the skills directory itself', () => { test('--superpowers-from accepts a path with spaces', () => { const home = tempDir() try { - const src = makeSuperpowers(join(home, 'super powers')) + const src = fixtureSuperpowers(home, null, join(home, 'super powers')) const run = runInstaller(home, ['--skills', '--superpowers-from', src]) assert.equal(run.status, 0, run.stdout + run.stderr) assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) @@ -149,15 +336,15 @@ test('--superpowers-from accepts a path with spaces', () => { test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => { const home = tempDir() try { - const fromFlag = makeSuperpowers(join(home, 'flag-source'), 'from-flag') - const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env') + const fromFlag = fixtureSuperpowers(home, 'from-flag', join(home, 'flag-source')) + const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source')) const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], { DSH_SUPERPOWERS_SOURCE: fromEnv, }) assert.equal(run.status, 0, run.stdout + run.stderr) assert.match( readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), - /from-flag/, + /install-source: from-flag/, ) } finally { cleanup(home) @@ -167,12 +354,12 @@ test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => { test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => { const home = tempDir() try { - const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env') + const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source')) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv }) assert.equal(run.status, 0, run.stdout + run.stderr) assert.match( readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), - /from-env/, + /install-source: from-env/, ) } finally { cleanup(home) @@ -182,11 +369,11 @@ test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => { test('--skills fails when there is no source and nothing installed', () => { const home = tempDir() try { - const run = runInstaller(home, ['--skills']) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) - assert.match(out, /git clone --depth 1 https:\/\/github\.com\/obra\/superpowers/) + assert.match(out, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed') assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')), 'local skills still installed') assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) @@ -195,15 +382,15 @@ test('--skills fails when there is no source and nothing installed', () => { } }) -test('--skills keeps installed copies when the source is gone', () => { +test('--skills offline without a source fails but keeps the installed copies', () => { const home = tempDir() try { const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') write(installed, '---\nname: using-superpowers\n---\ninstalled\n') - const run = runInstaller(home, ['--skills']) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr - assert.equal(run.status, 0, out) - assert.match(out, /WARN: no superpowers source/) + assert.equal(run.status, 1, out) + assert.match(out, /FAIL: no superpowers source/) assert.match(readFileSync(installed, 'utf8'), /installed/) } finally { cleanup(home) @@ -213,7 +400,10 @@ test('--skills keeps installed copies when the source is gone', () => { test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => { const home = tempDir() try { - const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: '' }) + const run = runInstaller(home, ['--skills'], { + DSH_SUPERPOWERS_SOURCE: '', + DSH_SUPERPOWERS_REPO: 'file:///nonexistent', + }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/) @@ -242,7 +432,7 @@ test('the legacy plugin cache still works and warns', () => { const home = tempDir() try { makeLegacyCache(home) - const run = runInstaller(home, ['--skills']) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 0, out) assert.match(out, /WARN: legacy superpowers source/) @@ -318,7 +508,7 @@ test('a legacy cache without using-superpowers is not a source', () => { 'skills', ) write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') - const run = runInstaller(home, ['--skills']) + const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }) const out = run.stdout + run.stderr assert.equal(run.status, 1, out) assert.match(out, /FAIL: no superpowers source/)