From 4054e13806c30107d8e338745346487a49d8601c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:20:29 +0400 Subject: [PATCH] =?UTF-8?q?fix(dsh):=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20legacy-=D0=BA?= =?UTF-8?q?=D1=8D=D1=88=20=D0=B8=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20?= =?UTF-8?q?--superpowers-from=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 21 ++++++++++++++++--- dsh/tests/install.test.mjs | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 2e7b7a2..989da7f 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -28,6 +28,7 @@ DO_SKILLS=0 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" usage() { @@ -73,15 +74,26 @@ while [ "$#" -gt 0 ]; do exit 2 fi SUPERPOWERS_FROM="$2" + SUPERPOWERS_FROM_SET=1 shift ;; - --superpowers-from=*) SUPERPOWERS_FROM="${1#*=}" ;; + --superpowers-from=*) + SUPERPOWERS_FROM="${1#*=}" + SUPERPOWERS_FROM_SET=1 + ;; -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; esac shift done +# An explicit flag with an empty value is a typo, not "use the default source": +# falling through to legacy would mask exactly the misconfiguration D-2 forbids. +if [ "$SUPERPOWERS_FROM_SET" = 1 ] && [ -z "$SUPERPOWERS_FROM" ]; then + echo "option --superpowers-from needs a non-empty directory" >&2 + exit 2 +fi + # Normalize a superpowers source: accept the repo root (with skills/) or the # skills directory itself. Prints the skills directory, returns 1 otherwise. normalize_superpowers_dir() { @@ -117,8 +129,11 @@ superpowers_source() { 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" ] && [ -d "$legacy" ]; then - printf '%s\tlegacy\n' "$legacy" + # A cache directory without using-superpowers is a broken cache, not a source: + # accepting it would copy 0 skills and report success (the silent empty + # bootstrap this change removes). + if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then + printf '%s\tlegacy\n' "$dir" return 0 fi if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 5922c75..e1623cf 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -303,3 +303,44 @@ test('--global installs DSH role paths and explains the instruction format', () cleanup(home) } }) + +test('a legacy cache without using-superpowers is not a source', () => { + const home = tempDir() + try { + const cache = join( + home, + '.claude', + 'plugins', + 'cache', + 'claude-plugins-official', + 'superpowers', + '7.0.0', + 'skills', + ) + write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') + const run = runInstaller(home, ['--skills']) + const out = run.stdout + run.stderr + assert.equal(run.status, 1, out) + assert.match(out, /FAIL: no superpowers source/) + assert.doesNotMatch(out, /copied 0 superpowers/) + assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false) + } finally { + cleanup(home) + } +}) + +test('an empty --superpowers-from is rejected, not ignored', () => { + const home = tempDir() + try { + makeLegacyCache(home) + for (const args of [['--skills', '--superpowers-from='], ['--skills', '--superpowers-from', '']]) { + const run = runInstaller(home, args) + const out = run.stdout + run.stderr + assert.equal(run.status, 2, out) + assert.match(out, /needs a non-empty directory/) + assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) + } + } finally { + cleanup(home) + } +})