From b292a38818628bb302f5ce8e8238b7929338ef72 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:35:56 +0400 Subject: [PATCH] feat(dsh): install pulls superpowers from git (#67) --- dsh/install.sh | 164 +++++++++++++++--- dsh/tests/install.test.mjs | 137 ++++++++++++++- .../specs/dsh-install/spec.md | 3 +- 3 files changed, 272 insertions(+), 32 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 989da7f..07797aa 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -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 < install a bundle from $BUNDLE @@ -110,8 +114,62 @@ normalize_superpowers_dir() { return 1 } -# Print "\t". 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 "\tgit:". +# 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 "\t". 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: 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 ; local skills (role, sdd) are installed by this run" >&2 + echo "FAIL: git source $SUPERPOWERS_REPO is unavailable; pass --superpowers-from 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() { diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index e1623cf..0854357 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -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) + } +}) diff --git a/openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md b/openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md index 9ab1fb8..798c984 100644 --- a/openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md +++ b/openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md @@ -16,7 +16,8 @@ legacy-fallback с предупреждением. - Репозиторий по умолчанию — `https://github.com/obra/superpowers`; `$DSH_SUPERPOWERS_REPO` MUST переопределять его (зеркала, локальные - репозитории в тестах). + репозитории в тестах) и MUST применяться к уже существующему клону: перед + `fetch` URL `origin` приводится к заданному репозиторию. - Клон — `git clone --depth 1 $DSH_HOME/superpowers`. - Обновление — `git -C $DSH_HOME/superpowers fetch --depth 1 origin` и `git reset --hard FETCH_HEAD`; рабочее дерево управляемое, локальные правки