feat(dsh): --skills гоняет sync/verify по пину v7.0.1, офлайн-кейсы (#69)
This commit is contained in:
parent
e94e842dfe
commit
335e966cfc
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
|
- **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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
274
dsh/install.sh
274
dsh/install.sh
@ -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() {
|
||||||
|
|||||||
@ -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/)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user