feat(dsh): install pulls superpowers from git (#67)

This commit is contained in:
Andrey Epifancev 2026-10-24 21:35:56 +04:00
parent c591083b37
commit b292a38818
3 changed files with 272 additions and 32 deletions

View File

@ -29,7 +29,8 @@ 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"
SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
CLONE_HINT="git clone --depth 1 $SUPERPOWERS_REPO $DSH_HOME/superpowers"
usage() {
cat <<EOF
@ -44,11 +45,14 @@ Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--
--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 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
3. git: clone or update \$DSH_HOME/superpowers from \$DSH_SUPERPOWERS_REPO
(default: https://github.com/obra/superpowers); no network when 1 or 2 is used
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns)
Offline: an existing git checkout is reused with a warning; a manual copy in
\$DSH_HOME/superpowers without .git is used as-is and is never updated.
Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE
@ -110,8 +114,62 @@ 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.
# Update (or create) the managed git checkout and print "<dir>\tgit:<sha>".
# A failed update keeps the cached checkout with a WARN; a failed first clone
# removes the directory and returns 1 so the caller can try legacy sources.
git_superpowers_source() {
local target="$DSH_HOME/superpowers"
local dir sha
if ! command -v git >/dev/null 2>&1; then
echo "WARN: git not found; cannot fetch superpowers from $SUPERPOWERS_REPO" >&2
return 1
fi
if [ -d "$target/.git" ]; then
# The configured repo must win over the URL recorded at clone time: a mirror
# switch (or a test fixture) has to affect an existing checkout as well.
local origin_url
origin_url="$(git -C "$target" remote get-url origin 2>/dev/null || true)"
if [ "$origin_url" != "$SUPERPOWERS_REPO" ]; then
git -C "$target" remote set-url origin "$SUPERPOWERS_REPO" >/dev/null 2>&1 || true
fi
if ! { git -C "$target" fetch --depth 1 origin >/dev/null 2>&1 \
&& git -C "$target" reset --hard FETCH_HEAD >/dev/null 2>&1; }; then
echo "WARN: cannot update superpowers ($SUPERPOWERS_REPO); using the cached checkout $target" >&2
fi
elif [ -d "$target" ]; then
# A manual copy from dsh-install-source (#49): usable, but not ours to update.
if dir="$(normalize_superpowers_dir "$target")"; then
echo "WARN: $target is not a git checkout; it will not be updated (use --superpowers-from to pin, or replace it with a clone)" >&2
printf '%s\tpinned\n' "$dir"
return 0
fi
echo "WARN: $target is not a git checkout and has no using-superpowers/SKILL.md" >&2
return 1
else
mkdir -p "$(dirname "$target")"
if ! git clone --depth 1 "$SUPERPOWERS_REPO" "$target" >/dev/null 2>&1; then
rm -rf "$target"
echo "WARN: git clone $SUPERPOWERS_REPO failed (offline?); no managed checkout" >&2
return 1
fi
fi
if ! dir="$(normalize_superpowers_dir "$target")"; then
echo "WARN: $target has no using-superpowers/SKILL.md after update" >&2
return 1
fi
sha="$(git -C "$target" rev-parse --short HEAD 2>/dev/null || true)"
if [ -n "$sha" ]; then
printf '%s\tgit:%s\n' "$dir" "$sha"
else
printf '%s\tgit\n' "$dir"
fi
return 0
}
# Network-capable resolver used by --skills. Explicit sources win and never
# touch git; then the managed checkout; then legacy Claude Code locations.
# Print "<skills-dir>\t<kind>". Returns 2 when an explicitly configured source
# is invalid (never fall back silently), 1 when none is found.
superpowers_source() {
local dir legacy
if [ -n "$SUPERPOWERS_FROM" ]; then
@ -124,8 +182,7 @@ superpowers_source() {
printf '%s\texplicit\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
printf '%s\tpinned\n' "$dir"
if git_superpowers_source; then
return 0
fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
@ -143,6 +200,45 @@ superpowers_source() {
return 1
}
# Offline resolver used by --check: it never clones, fetches or resets.
# Same output format, plus git:<sha> for a managed checkout.
static_superpowers_source() {
local dir legacy sha target="$DSH_HOME/superpowers"
if [ -n "$SUPERPOWERS_FROM" ]; then
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$target")"; then
if [ -d "$target/.git" ]; then
sha="$(git -C "$target" rev-parse --short HEAD 2>/dev/null || true)"
if [ -n "$sha" ]; then
printf '%s\tgit:%s\n' "$dir" "$sha"
else
printf '%s\tgit\n' "$dir"
fi
else
printf '%s\tpinned\n' "$dir"
fi
return 0
fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
return 1
}
# Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then
@ -218,8 +314,9 @@ check() {
else
echo "WARN: .agents/skills missing; run --openspec in the project" >&2
fi
local src="" rc=0 kind
src="$(superpowers_source)" || rc=$?
local src="" rc=0 kind sha
# --check must stay offline: this resolver reads the existing checkout only.
src="$(static_superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source
failed=1
@ -227,16 +324,29 @@ check() {
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "WARN: no superpowers source; installed skills in $DSH_HOME/skills are used" >&2
else
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2
echo "WARN: no superpowers source; run --skills to clone $SUPERPOWERS_REPO" >&2
fi
else
kind="${src##*$'\t'}"
src="${src%%$'\t'*}"
if [ "$kind" = legacy ]; then
echo "WARN: legacy superpowers source ($src); prefer --superpowers-from or $DSH_HOME/superpowers" >&2
else
echo "ok: superpowers source ($src)"
fi
case "$kind" in
legacy)
echo "WARN: legacy superpowers source ($src); prefer a git checkout or --superpowers-from" >&2
;;
pinned)
echo "WARN: superpowers source $src is not a git checkout; --skills will not update it" >&2
;;
git:*)
sha="${kind#git:}"
echo "ok: superpowers source ($src, git $sha)"
;;
git)
echo "ok: superpowers source ($src, git)"
;;
*)
echo "ok: superpowers source ($src)"
;;
esac
fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "ok: dsh skills ($DSH_HOME/skills)"
@ -310,19 +420,25 @@ install_skills() {
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
echo "WARN: next time run --skills (git source: $SUPERPOWERS_REPO) or pass --superpowers-from" >&2
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
echo "FAIL: git source $SUPERPOWERS_REPO is unavailable; pass --superpowers-from <dir> or set DSH_SUPERPOWERS_REPO" >&2
echo "FAIL: local skills (role, sdd) are installed by this run" >&2
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
fi
local sha=""
case "$kind" in
legacy)
echo "WARN: legacy superpowers source $source; prefer a git checkout or --superpowers-from" >&2
;;
git:*)
sha="${kind#git:}"
;;
esac
local dir name dest
for dir in "$source"/*; do
[ -f "$dir/SKILL.md" ] || continue
@ -332,7 +448,11 @@ install_skills() {
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)"
if [ -n "$sha" ]; then
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source, git $sha)"
else
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)"
fi
}
install_local_skills() {

View File

@ -1,10 +1,10 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { cleanup, tempDir, write } from './helpers.mjs'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { cleanup, git, tempDir, write } from './helpers.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const script = join(here, '..', 'install.sh')
@ -26,7 +26,12 @@ 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: join(home, 'no-superpowers-repo'),
}
const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
const first = run()
@ -93,9 +98,29 @@ function makeLegacyCache(home) {
function installerEnv(home) {
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
delete env.DSH_SUPERPOWERS_SOURCE
delete env.DSH_SUPERPOWERS_REPO
// No test may reach GitHub: the git source defaults to a path that does not
// exist, and git tests opt in with a file:// fixture.
env.DSH_SUPERPOWERS_REPO = join(home, 'no-superpowers-repo')
return env
}
function makeSuperpowersRepo(home, name = 'superpowers-src') {
const repo = join(home, name)
mkdirSync(repo, { recursive: true })
git(repo, ['init', '-b', 'main'])
git(repo, ['config', 'user.email', 'test@example.invalid'])
git(repo, ['config', 'user.name', 'Test'])
makeSuperpowers(repo)
git(repo, ['add', '.'])
git(repo, ['commit', '-m', 'init'])
return repo
}
function gitUrl(repo) {
return pathToFileURL(repo).href
}
function runInstaller(home, args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], {
env: { ...installerEnv(home), ...extraEnv },
@ -186,7 +211,8 @@ test('--skills fails when there is no source and nothing installed', () => {
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, /no-superpowers-repo/)
assert.match(out, /pass --superpowers-from/)
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)
@ -253,15 +279,14 @@ test('the legacy plugin cache still works and warns', () => {
}
})
test('--check reports the pinned source', () => {
test('--check warns about a manual non-git checkout', () => {
const home = tempDir()
try {
const pinned = join(home, '.dsh', 'superpowers')
makeSuperpowers(pinned)
makeSuperpowers(join(home, '.dsh', 'superpowers'))
const run = runInstaller(home, ['--check'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /ok: superpowers source \(.*superpowers\/skills\)/)
assert.match(out, /not a git checkout; --skills will not update it/)
} finally {
cleanup(home)
}
@ -344,3 +369,97 @@ test('an empty --superpowers-from is rejected, not ignored', () => {
cleanup(home)
}
})
test('--skills clones the git source on first run', () => {
const home = tempDir()
try {
const repo = makeSuperpowersRepo(home)
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) })
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.ok(existsSync(join(home, '.dsh', 'superpowers', '.git')), 'clone is missing')
assert.match(run.stdout, /copied 2 superpowers skills/)
assert.ok(run.stdout.includes(`git ${sha}`), out)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
} finally {
cleanup(home)
}
})
test('--skills updates an existing clone to the new commit', () => {
const home = tempDir()
try {
const repo = makeSuperpowersRepo(home)
const env = { DSH_SUPERPOWERS_REPO: gitUrl(repo) }
assert.equal(runInstaller(home, ['--skills'], env).status, 0)
write(join(repo, 'skills', 'new-skill', 'SKILL.md'), '---\nname: new-skill\n---\nbody\n')
git(repo, ['add', '.'])
git(repo, ['commit', '-m', 'add new-skill'])
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
const second = runInstaller(home, ['--skills'], env)
const out = second.stdout + second.stderr
assert.equal(second.status, 0, out)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'new-skill', 'SKILL.md')), 'clone was not updated')
assert.ok(second.stdout.includes(`git ${sha}`), out)
} finally {
cleanup(home)
}
})
test('an offline update keeps the cached clone', () => {
const home = tempDir()
try {
const repo = makeSuperpowersRepo(home)
assert.equal(runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) }).status, 0)
const offline = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: join(home, 'gone') })
const out = offline.stdout + offline.stderr
assert.equal(offline.status, 0, out)
assert.match(out, /WARN: cannot update superpowers/)
assert.match(offline.stdout, /copied 2 superpowers skills/)
} finally {
cleanup(home)
}
})
test('a failed first clone leaves no checkout behind', () => {
const home = tempDir()
try {
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false, 'broken clone must not stay')
assert.match(out, /FAIL: no superpowers source/)
} finally {
cleanup(home)
}
})
test('an explicit source never touches git', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false)
} finally {
cleanup(home)
}
})
test('a manual non-git checkout is used but not updated', () => {
const home = tempDir()
try {
makeSuperpowers(join(home, '.dsh', 'superpowers'))
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /not a git checkout/)
assert.match(run.stdout, /copied 2 superpowers skills/)
} finally {
cleanup(home)
}
})

View File

@ -16,7 +16,8 @@ legacy-fallback с предупреждением.
- Репозиторий по умолчанию — `https://github.com/obra/superpowers`;
`$DSH_SUPERPOWERS_REPO` MUST переопределять его (зеркала, локальные
репозитории в тестах).
репозитории в тестах) и MUST применяться к уже существующему клону: перед
`fetch` URL `origin` приводится к заданному репозиторию.
- Клон — `git clone --depth 1 <repo> $DSH_HOME/superpowers`.
- Обновление — `git -C $DSH_HOME/superpowers fetch --depth 1 origin` и
`git reset --hard FETCH_HEAD`; рабочее дерево управляемое, локальные правки