feat(dsh): --skills гоняет sync/verify по пину v7.0.1, офлайн-кейсы (#69)

This commit is contained in:
Andrey Epifancev 2026-10-25 00:06:24 +04:00
parent e94e842dfe
commit 335e966cfc
4 changed files with 490 additions and 102 deletions

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 - **Full installer**: `install.sh` prepares global instructions and skills; the
bundle itself is installed by `plugin_manager` because the Desktop profile is bundle itself is installed by `plugin_manager` because the Desktop profile is
Electron-managed. Electron-managed.
- **Vendored superpowers**: the install-time source is explicit - **Upstream pin (change `dsh-port-v2`, D-2)**: the default install source is
(`--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or a clone in the clone at `~/.dsh/superpowers` pinned to `$DSH_SUPERPOWERS_REF` (default
`~/.dsh/superpowers`); a copy inside this repository is rejected because `v7.0.1`). `install.sh --skills` clones or updates it, runs the vendored
upstream moves independently (change `dsh-install-source`, D-1). Removing the sync + verify, and copies the adapted skills; a broken or empty synced tree
legacy `~/.claude` fallbacks is deferred until every machine has rerun is never installed. Explicit sources (`--superpowers-from`,
`--skills --roles`; until then both runtime and installer warn when they use `$DSH_SUPERPOWERS_SOURCE`) are synced directly with no network. Upstream
one. 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 ## 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. bundle logs one warning per process when it resolves one of them.
Install-time source resolution is separate and stricter (change Install-time source resolution is separate and stricter (change
`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → `dsh-install-source`, pin in change `dsh-port-v2`): `--superpowers-from` →
`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An `$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at
invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a `$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
fresh install and a `WARN` when `~/.dsh/skills` already holds the skills. `$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 ### OpenSpec skills
@ -243,20 +251,21 @@ openspec archive <id> -y
## 6. Install and verify ## 6. Install and verify
Prerequisites: the `claude-harness` checkout, a superpowers source, DSH Prerequisites: the `claude-harness` checkout, DSH 0.2.1-rc.3, `python3`,
0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the `git`, Node. Claude Code is not required at all: `--skills` clones the pinned
superpowers source once with superpowers checkout itself (`DSH_SUPERPOWERS_REF`, default `v7.0.1`) and runs
`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`, the DSH sync + verify before installing, so a manual clone is no longer the
then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this first step; `--roles` copies the role files; the hooks come from this
checkout. checkout.
```bash ```bash
# 1. Static checks # 1. Static checks (offline; reports the configured pin and the source ref)
bash dsh/install.sh --check bash dsh/install.sh --check
# 2. Global instructions, skills, and roles (run from a terminal; writes # 2. Global instructions, skills, and roles (run from a terminal; writes
# outside the session workspace). The superpowers source comes from # outside the session workspace). --skills clones the pinned superpowers
# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/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 --global
bash dsh/install.sh --skills # or: --skills --superpowers-from DIR bash dsh/install.sh --skills # or: --skills --superpowers-from DIR
bash dsh/install.sh --roles bash dsh/install.sh --roles

View File

@ -30,20 +30,21 @@ risks.
## Quick start ## Quick start
```bash ```bash
# Static check # Static check (offline; reports the configured pin and the current source)
bash dsh/install.sh --check bash dsh/install.sh --check
# Superpowers source: get it once per machine. install.sh never downloads # Superpowers source. The default is a pinned clone: --skills clones or
# anything and no longer depends on ~/.claude. # updates ~/.dsh/superpowers at $DSH_SUPERPOWERS_REF (default v7.0.1, fetched
git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers # from $DSH_SUPERPOWERS_REPO), then runs sync + verify and copies the adapted
# ...or point at any checkout of the superpowers repo / its skills directory: # 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 bash dsh/install.sh --skills --superpowers-from /path/to/superpowers
# ...or export DSH_SUPERPOWERS_SOURCE=/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) # (run from a terminal; these write outside the session workspace)
bash dsh/install.sh --global bash dsh/install.sh --global
bash dsh/install.sh --skills
bash dsh/install.sh --roles bash dsh/install.sh --roles
# В корне проекта, где уже есть openspec/: добавить agents-скиллы # В корне проекта, где уже есть openspec/: добавить agents-скиллы
@ -55,10 +56,15 @@ bash <harness>/dsh/install.sh --openspec
``` ```
`--skills` resolves superpowers in this order: `--superpowers-from` → `--skills` resolves superpowers in this order: `--superpowers-from` →
`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache `$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at
(legacy, warns). A clean HOME with no source and no installed copy fails with `$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
the clone command above instead of pretending success; the local `role` and `$DSH_SUPERPOWERS_REPO`; offline it is reused only when already on the ref) →
`sdd` skills are copied either way. 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 Do not hand-edit `$DSH_HOME/profiles/desktop/package.json` or
`cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin `cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin
@ -157,7 +163,8 @@ repository layout:
`files`). `files`).
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The - `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The
install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or 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 fallback** and log one warning per process when used, so a partial Claude
Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or
`DSH_SUPERPOWERS_SKILL` for another layout. `DSH_SUPERPOWERS_SKILL` for another layout.

View File

@ -22,6 +22,8 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUNDLE="$ROOT/dsh/bundle" BUNDLE="$ROOT/dsh/bundle"
DSH_HOME="${DSH_HOME:-$HOME/.dsh}" 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_CHECK=0
DO_GLOBAL=0 DO_GLOBAL=0
DO_SKILLS=0 DO_SKILLS=0
@ -29,26 +31,36 @@ DO_ROLES=0
DO_OPENSPEC=0 DO_OPENSPEC=0
SUPERPOWERS_FROM="" SUPERPOWERS_FROM=""
SUPERPOWERS_FROM_SET=0 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() { usage() {
cat <<EOF cat <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec] Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
bash dsh/install.sh --skills --superpowers-from <dir> 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 --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 --roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step --all --check + --global + --skills + --roles, then print the bundle step
--openspec add the agents skill adapter to the current OpenSpec project --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 1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE 2. \$DSH_SUPERPOWERS_SOURCE
3. \$DSH_HOME/superpowers -- get it once: $CLONE_HINT 3. the pinned clone \$DSH_HOME/superpowers at \$DSH_SUPERPOWERS_REF
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns) (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: Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE Plugins -> install a bundle from $BUNDLE
@ -110,39 +122,94 @@ normalize_superpowers_dir() {
return 1 return 1
} }
# Print "<skills-dir>\t<explicit|pinned|legacy>". Returns 2 when an explicitly # The checkout root the sync script consumes: <root>/skills must exist.
# configured source is invalid (never fall back silently), 1 when none is found. # 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() { superpowers_source() {
local dir legacy local skills="" checkout="" legacy=""
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2 skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
printf '%s\texplicit\n' "$dir" checkout="$(checkout_root "$skills" 2>/dev/null || true)"
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills"
return 0 return 0
fi fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2 skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
printf '%s\texplicit\n' "$dir" checkout="$(checkout_root "$skills" 2>/dev/null || true)"
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills"
return 0 return 0
fi fi
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
printf '%s\tpinned\n' "$dir" printf 'pinned\t%s\t%s\n' "$DSH_HOME/superpowers" "$skills"
return 0 return 0
fi fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" 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: # 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 # accepting it would copy 0 skills and report success (the silent empty
# bootstrap this change removes). # bootstrap this change removes).
if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then if [ -n "$legacy" ] && skills="$(normalize_superpowers_dir "$legacy")"; then
printf '%s\tlegacy\n' "$dir" printf 'legacy\t-\t%s\n' "$skills"
return 0 return 0
fi fi
if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then if skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
printf '%s\tlegacy\n' "$dir" printf 'legacy\t-\t%s\n' "$skills"
return 0 return 0
fi fi
return 1 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. # Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() { explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
@ -218,7 +285,10 @@ check() {
else else
echo "WARN: .agents/skills missing; run --openspec in the project" >&2 echo "WARN: .agents/skills missing; run --openspec in the project" >&2
fi 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=$? src="$(superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source report_invalid_explicit_source
@ -230,12 +300,12 @@ check() {
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2 echo "WARN: no superpowers source; run: $CLONE_HINT" >&2
fi fi
else else
kind="${src##*$'\t'}" kind="${src%%$'\t'*}"; src="${src#*$'\t'}"
src="${src%%$'\t'*}" checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}"
if [ "$kind" = legacy ]; then 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 else
echo "ok: superpowers source ($src)" echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout")"
fi fi
fi fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
@ -299,32 +369,85 @@ PY
echo "written: $target" echo "written: $target"
} }
install_skills() { # The default source: a clone at $DSH_HOME/superpowers pinned to $REF.
mkdir -p "$DSH_HOME/skills" # Returns 0 when the clone is usable at the ref, 1 when a fresh clone failed
local line="" rc=0 source kind count=0 # (the caller may fall back to legacy), 2 when pre-existing content blocks the
line="$(superpowers_source)" || rc=$? # pin (hard fail: the directory is never touched).
if [ "$rc" -eq 2 ]; then ensure_pinned_clone() {
report_invalid_explicit_source local dir="$1" ref="$2"
return 1 if [ -e "$dir" ] && [ ! -d "$dir" ]; then
echo "FAIL: $dir exists and is not a directory; the pinned clone needs it" >&2
return 2
fi fi
if [ "$rc" -ne 0 ]; then if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then if [ ! -d "$dir/.git" ] || ! git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "WARN: no superpowers source found; keeping installed skills in $DSH_HOME/skills" >&2 echo "FAIL: $dir has no usable .git: not a pinned superpowers clone" >&2
echo "WARN: next time run: $CLONE_HINT" >&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 return 0
fi fi
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2 # Offline: reuse only when HEAD is exactly at the pinned ref.
echo "FAIL: get the source once: $CLONE_HINT" >&2 if git -C "$dir" rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 &&
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2 [ "$(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 return 1
fi fi
kind="${line##*$'\t'}" echo "ok: cloned superpowers at $ref into $dir"
source="${line%%$'\t'*}" return 0
if [ "$kind" = legacy ]; then }
echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2
# 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 fi
local dir name dest verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$SUPERPOWERS_OUT" 2>&1)" || verify_rc=$?
for dir in "$source"/*; do 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 [ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")" name="$(basename "$dir")"
dest="$DSH_HOME/skills/$name" dest="$DSH_HOME/skills/$name"
@ -332,7 +455,66 @@ install_skills() {
cp -R "$dir" "$dest" cp -R "$dir" "$dest"
count=$((count + 1)) count=$((count + 1))
done 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() { install_local_skills() {

View File

@ -1,13 +1,15 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync } from 'node:fs' import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join, resolve } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' 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 here = dirname(fileURLToPath(import.meta.url))
const script = join(here, '..', 'install.sh') 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', () => { test('install.sh copies skills and roles instead of linking into ~/.claude', () => {
const home = tempDir() 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, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\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 run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
const first = run() const first = run()
@ -90,9 +94,49 @@ function makeLegacyCache(home) {
return join(root, 'skills') 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) { function installerEnv(home) {
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') } const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
delete env.DSH_SUPERPOWERS_SOURCE delete env.DSH_SUPERPOWERS_SOURCE
delete env.DSH_SUPERPOWERS_REPO
delete env.DSH_SUPERPOWERS_REF
return env 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() const home = tempDir()
try { 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 run = runInstaller(home, ['--skills', '--superpowers-from', src])
const out = run.stdout + run.stderr const out = run.stdout + run.stderr
assert.equal(run.status, 0, out) assert.equal(run.status, 0, out)
assert.match(run.stdout, /copied 2 superpowers skills/) assert.match(run.stdout, /synced \d+ files/)
assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), out) 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', 'using-superpowers', 'SKILL.md')))
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md'))) assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')))
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', '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', () => { test('--superpowers-from accepts the skills directory itself', () => {
const home = tempDir() const home = tempDir()
try { 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')]) const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')])
assert.equal(run.status, 0, run.stdout + run.stderr) 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 { } finally {
cleanup(home) cleanup(home)
} }
@ -137,7 +324,7 @@ test('--superpowers-from accepts the skills directory itself', () => {
test('--superpowers-from accepts a path with spaces', () => { test('--superpowers-from accepts a path with spaces', () => {
const home = tempDir() const home = tempDir()
try { 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]) const run = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(run.status, 0, run.stdout + run.stderr) assert.equal(run.status, 0, run.stdout + run.stderr)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))) 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', () => { test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => {
const home = tempDir() const home = tempDir()
try { try {
const fromFlag = makeSuperpowers(join(home, 'flag-source'), 'from-flag') const fromFlag = fixtureSuperpowers(home, 'from-flag', join(home, 'flag-source'))
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env') const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source'))
const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], { const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], {
DSH_SUPERPOWERS_SOURCE: fromEnv, DSH_SUPERPOWERS_SOURCE: fromEnv,
}) })
assert.equal(run.status, 0, run.stdout + run.stderr) assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match( assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-flag/, /install-source: from-flag/,
) )
} finally { } finally {
cleanup(home) 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', () => { test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
const home = tempDir() const home = tempDir()
try { 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 }) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv })
assert.equal(run.status, 0, run.stdout + run.stderr) assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match( assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'), readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-env/, /install-source: from-env/,
) )
} finally { } finally {
cleanup(home) 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', () => { test('--skills fails when there is no source and nothing installed', () => {
const home = tempDir() const home = tempDir()
try { try {
const run = runInstaller(home, ['--skills']) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
const out = run.stdout + run.stderr const out = run.stdout + run.stderr
assert.equal(run.status, 1, out) assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/) 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', 'role', 'SKILL.md')), 'local skills still installed')
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', '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) 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() const home = tempDir()
try { try {
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
write(installed, '---\nname: using-superpowers\n---\ninstalled\n') 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 const out = run.stdout + run.stderr
assert.equal(run.status, 0, out) assert.equal(run.status, 1, out)
assert.match(out, /WARN: no superpowers source/) assert.match(out, /FAIL: no superpowers source/)
assert.match(readFileSync(installed, 'utf8'), /installed/) assert.match(readFileSync(installed, 'utf8'), /installed/)
} finally { } finally {
cleanup(home) cleanup(home)
@ -213,7 +400,10 @@ test('--skills keeps installed copies when the source is gone', () => {
test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => { test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => {
const home = tempDir() const home = tempDir()
try { 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 const out = run.stdout + run.stderr
assert.equal(run.status, 1, out) assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/) assert.match(out, /FAIL: no superpowers source/)
@ -242,7 +432,7 @@ test('the legacy plugin cache still works and warns', () => {
const home = tempDir() const home = tempDir()
try { try {
makeLegacyCache(home) makeLegacyCache(home)
const run = runInstaller(home, ['--skills']) const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
const out = run.stdout + run.stderr const out = run.stdout + run.stderr
assert.equal(run.status, 0, out) assert.equal(run.status, 0, out)
assert.match(out, /WARN: legacy superpowers source/) assert.match(out, /WARN: legacy superpowers source/)
@ -318,7 +508,7 @@ test('a legacy cache without using-superpowers is not a source', () => {
'skills', 'skills',
) )
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') 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 const out = run.stdout + run.stderr
assert.equal(run.status, 1, out) assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/) assert.match(out, /FAIL: no superpowers source/)