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 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" SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
CLONE_HINT="git clone --depth 1 $SUPERPOWERS_REPO $DSH_HOME/superpowers"
usage() { usage() {
cat <<EOF 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 --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 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 1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE 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) 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: Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE Plugins -> install a bundle from $BUNDLE
@ -110,8 +114,62 @@ normalize_superpowers_dir() {
return 1 return 1
} }
# Print "<skills-dir>\t<explicit|pinned|legacy>". Returns 2 when an explicitly # Update (or create) the managed git checkout and print "<dir>\tgit:<sha>".
# configured source is invalid (never fall back silently), 1 when none is found. # 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() { superpowers_source() {
local dir legacy local dir legacy
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
@ -124,8 +182,7 @@ superpowers_source() {
printf '%s\texplicit\n' "$dir" printf '%s\texplicit\n' "$dir"
return 0 return 0
fi fi
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then if git_superpowers_source; then
printf '%s\tpinned\n' "$dir"
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)"
@ -143,6 +200,45 @@ superpowers_source() {
return 1 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. # 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,8 +314,9 @@ 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 local src="" rc=0 kind sha
src="$(superpowers_source)" || rc=$? # --check must stay offline: this resolver reads the existing checkout only.
src="$(static_superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source report_invalid_explicit_source
failed=1 failed=1
@ -227,16 +324,29 @@ check() {
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "WARN: no superpowers source; installed skills in $DSH_HOME/skills are used" >&2 echo "WARN: no superpowers source; installed skills in $DSH_HOME/skills are used" >&2
else else
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2 echo "WARN: no superpowers source; run --skills to clone $SUPERPOWERS_REPO" >&2
fi fi
else else
kind="${src##*$'\t'}" kind="${src##*$'\t'}"
src="${src%%$'\t'*}" src="${src%%$'\t'*}"
if [ "$kind" = legacy ]; then case "$kind" in
echo "WARN: legacy superpowers source ($src); prefer --superpowers-from or $DSH_HOME/superpowers" >&2 legacy)
else echo "WARN: legacy superpowers source ($src); prefer a git checkout or --superpowers-from" >&2
echo "ok: superpowers source ($src)" ;;
fi 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 fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "ok: dsh skills ($DSH_HOME/skills)" echo "ok: dsh skills ($DSH_HOME/skills)"
@ -310,19 +420,25 @@ install_skills() {
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; 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: 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 return 0
fi fi
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2 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: git source $SUPERPOWERS_REPO is unavailable; pass --superpowers-from <dir> or set DSH_SUPERPOWERS_REPO" >&2
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2 echo "FAIL: local skills (role, sdd) are installed by this run" >&2
return 1 return 1
fi fi
kind="${line##*$'\t'}" kind="${line##*$'\t'}"
source="${line%%$'\t'*}" source="${line%%$'\t'*}"
if [ "$kind" = legacy ]; then local sha=""
echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2 case "$kind" in
fi legacy)
echo "WARN: legacy superpowers source $source; prefer a git checkout or --superpowers-from" >&2
;;
git:*)
sha="${kind#git:}"
;;
esac
local dir name dest local dir name dest
for dir in "$source"/*; do for dir in "$source"/*; do
[ -f "$dir/SKILL.md" ] || continue [ -f "$dir/SKILL.md" ] || continue
@ -332,7 +448,11 @@ 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)" 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() { install_local_skills() {

View File

@ -1,10 +1,10 @@
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 { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath, pathToFileURL } 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')
@ -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, '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: join(home, 'no-superpowers-repo'),
}
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()
@ -93,9 +98,29 @@ function makeLegacyCache(home) {
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
// 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 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 = {}) { function runInstaller(home, args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], { return spawnSync('bash', [script, ...args], {
env: { ...installerEnv(home), ...extraEnv }, 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 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, /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', '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)
@ -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() const home = tempDir()
try { try {
const pinned = join(home, '.dsh', 'superpowers') makeSuperpowers(join(home, '.dsh', 'superpowers'))
makeSuperpowers(pinned)
const run = runInstaller(home, ['--check']) const run = runInstaller(home, ['--check'])
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, /ok: superpowers source \(.*superpowers\/skills\)/) assert.match(out, /not a git checkout; --skills will not update it/)
} finally { } finally {
cleanup(home) cleanup(home)
} }
@ -344,3 +369,97 @@ test('an empty --superpowers-from is rejected, not ignored', () => {
cleanup(home) 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`; - Репозиторий по умолчанию — `https://github.com/obra/superpowers`;
`$DSH_SUPERPOWERS_REPO` MUST переопределять его (зеркала, локальные `$DSH_SUPERPOWERS_REPO` MUST переопределять его (зеркала, локальные
репозитории в тестах). репозитории в тестах) и MUST применяться к уже существующему клону: перед
`fetch` URL `origin` приводится к заданному репозиторию.
- Клон — `git clone --depth 1 <repo> $DSH_HOME/superpowers`. - Клон — `git clone --depth 1 <repo> $DSH_HOME/superpowers`.
- Обновление — `git -C $DSH_HOME/superpowers fetch --depth 1 origin` и - Обновление — `git -C $DSH_HOME/superpowers fetch --depth 1 origin` и
`git reset --hard FETCH_HEAD`; рабочее дерево управляемое, локальные правки `git reset --hard FETCH_HEAD`; рабочее дерево управляемое, локальные правки