DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69) #72
47
dsh/PORT.md
47
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 <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
|
||||
|
||||
@ -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.
|
||||
|
||||
274
dsh/install.sh
274
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 <<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() {
|
||||
|
||||
@ -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/)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user