DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69) #72

Merged
aep merged 19 commits from feat/69-dsh-port-v2 into main 2026-10-25 00:12:57 +03:00
4 changed files with 490 additions and 102 deletions
Showing only changes of commit 335e966cfc - Show all commits

View File

@ -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 <id> -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

View File

@ -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 <harness>/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.

View File

@ -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 <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
bash dsh/install.sh --skills --superpowers-from <dir>
--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 "<skills-dir>\t<explicit|pinned|legacy>". 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: <root>/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 "<kind>\t<checkout-root-or--->\t<skills-dir>". 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 <ref>` (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 <dir>; 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 <dir>/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 <dir>; 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() {

View File

@ -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<!-- install-source: ${marker} -->\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<!-- move-marker: second -->\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/)