feat(dsh): install superpowers from an explicit source (#49)

This commit is contained in:
Andrey Epifancev 2026-10-24 21:10:52 +04:00
parent 15cc2e38d7
commit 05178869fb
5 changed files with 353 additions and 28 deletions

View File

@ -33,6 +33,13 @@ risks.
# Static check
bash dsh/install.sh --check
# Superpowers source: get it once per machine. install.sh never downloads
# anything and no longer depends on ~/.claude.
git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers
# ...or point at any checkout of the superpowers repo / its skills directory:
bash dsh/install.sh --skills --superpowers-from /path/to/superpowers
# ...or export DSH_SUPERPOWERS_SOURCE=/path/to/superpowers
# Global instructions, superpowers skills, local role skill, and roles
# (run from a terminal; these write outside the session workspace)
bash dsh/install.sh --global
@ -47,6 +54,12 @@ bash <harness>/dsh/install.sh --openspec
# or ask the agent to call plugin_manager install_bundle with that target.
```
`--skills` resolves superpowers in this order: `--superpowers-from` →
`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache
(legacy, warns). A clean HOME with no source and no installed copy fails with
the clone command above 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
`cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin
manager owns those writes.

View File

@ -10,7 +10,7 @@
# Usage:
# bash dsh/install.sh --check
# bash dsh/install.sh --global
# bash dsh/install.sh --skills
# bash dsh/install.sh --skills [--superpowers-from <dir>]
# bash dsh/install.sh --roles
# bash dsh/install.sh --all
# bash dsh/install.sh --openspec
@ -27,17 +27,27 @@ DO_GLOBAL=0
DO_SKILLS=0
DO_ROLES=0
DO_OPENSPEC=0
SUPERPOWERS_FROM=""
CLONE_HINT="git clone --depth 1 https://github.com/obra/superpowers $DSH_HOME/superpowers"
usage() {
cat <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
bash dsh/install.sh --skills --superpowers-from <dir>
--check verify python3, git, node, hooks, bundle, skills, and roles
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills copy superpowers skills and local dsh/skills into \$DSH_HOME/skills
--roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step
--openspec add the agents skill adapter to the current OpenSpec project
--check verify python3, git, node, hooks, bundle, skills, and roles
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills copy superpowers and local dsh/skills into \$DSH_HOME/skills
--roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step
--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:
1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE
3. \$DSH_HOME/superpowers -- get it once: $CLONE_HINT
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns)
Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE
@ -49,29 +59,88 @@ if [ "$#" -eq 0 ]; then
DO_CHECK=1
fi
for arg in "$@"; do
case "$arg" in
while [ "$#" -gt 0 ]; do
case "$1" in
--check) DO_CHECK=1 ;;
--global) DO_GLOBAL=1 ;;
--skills) DO_SKILLS=1 ;;
--roles) DO_ROLES=1 ;;
--openspec) DO_OPENSPEC=1 ;;
--all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1; DO_ROLES=1 ;;
--superpowers-from)
if [ "$#" -lt 2 ]; then
echo "option --superpowers-from needs a directory" >&2
exit 2
fi
SUPERPOWERS_FROM="$2"
shift
;;
--superpowers-from=*) SUPERPOWERS_FROM="${1#*=}" ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
superpowers_skills() {
local dir
dir="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$dir" ] && [ -d "$dir" ]; then
# 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() {
local dir="${1:-}"
[ -n "$dir" ] || return 1
if [ -f "$dir/skills/using-superpowers/SKILL.md" ]; then
printf '%s\n' "$dir/skills"
return 0
fi
if [ -f "$dir/using-superpowers/SKILL.md" ]; then
printf '%s\n' "$dir"
return 0
fi
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.
superpowers_source() {
local dir legacy
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 "$DSH_HOME/superpowers")"; then
printf '%s\tpinned\n' "$dir"
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"
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
printf '%s\n' "--superpowers-from $SUPERPOWERS_FROM"
else
printf '%s\n' "DSH_SUPERPOWERS_SOURCE=$DSH_SUPERPOWERS_SOURCE"
fi
}
report_invalid_explicit_source() {
echo "FAIL: explicit superpowers source $(explicit_source_name) has no using-superpowers/SKILL.md (expected <dir>/skills/... or <dir>/...)" >&2
}
check() {
local failed=0
for cmd in python3 git node; do
@ -134,12 +203,30 @@ check() {
else
echo "WARN: .agents/skills missing; run --openspec in the project" >&2
fi
local src="" rc=0 kind
src="$(superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source
failed=1
elif [ "$rc" -ne 0 ]; 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
else
echo "WARN: no superpowers source; run: $CLONE_HINT" >&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
fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "ok: dsh skills ($DSH_HOME/skills)"
elif superpowers_skills >/dev/null 2>&1; then
echo "ok: superpowers source ($(superpowers_skills)); run --skills to copy"
else
echo "WARN: superpowers plugin not found; the bootstrap will be skipped" >&2
elif [ "$rc" -eq 0 ]; then
echo "WARN: superpowers skills are not copied yet; run --skills" >&2
fi
if [ -f "$DSH_HOME/skills/role/SKILL.md" ]; then
echo "ok: role skill"
@ -190,14 +277,30 @@ install_global() {
install_skills() {
mkdir -p "$DSH_HOME/skills"
local skills
if ! skills="$(superpowers_skills)"; then
echo "WARN: superpowers skills not found; nothing copied" >&2
return 0
local line="" rc=0 source kind count=0
line="$(superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source
return 1
fi
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
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
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 count=0
local dir name dest
for dir in "$skills"/*; do
for dir in "$source"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
dest="$DSH_HOME/skills/$name"
@ -205,7 +308,7 @@ install_skills() {
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "copied $count superpowers skills into $DSH_HOME/skills"
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)"
}
install_local_skills() {
@ -321,7 +424,9 @@ if [ "$DO_GLOBAL" = 1 ]; then
install_global
fi
if [ "$DO_SKILLS" = 1 ]; then
install_skills
# Local skills do not depend on superpowers: install them even when the
# superpowers source is missing (the FAIL is reported by the exit status).
install_skills || status=1
install_local_skills
fi
if [ "$DO_ROLES" = 1 ]; then

View File

@ -76,3 +76,206 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
function countNotes(text) {
return (text.match(/В DSH-сессии исполнение плана/g) ?? []).length
}
function makeSuperpowers(root, body = 'body') {
for (const name of ['using-superpowers', 'brainstorming']) {
write(join(root, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n${body}\n`)
}
return root
}
function makeLegacyCache(home) {
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers', '7.0.0')
makeSuperpowers(root)
return join(root, 'skills')
}
function installerEnv(home) {
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
delete env.DSH_SUPERPOWERS_SOURCE
return env
}
function runInstaller(home, args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], {
env: { ...installerEnv(home), ...extraEnv },
encoding: 'utf8',
})
}
test('--skills takes superpowers from --superpowers-from in a clean HOME', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(run.stdout, /copied 2 superpowers skills/)
assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), out)
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', 'sdd', 'SKILL.md')))
assert.equal(existsSync(join(home, '.claude')), false, 'clean HOME must stay clean')
assert.doesNotMatch(out, /legacy/)
} finally {
cleanup(home)
}
})
test('--superpowers-from accepts the skills directory itself', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')])
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(run.stdout, /copied 2 superpowers skills/)
} finally {
cleanup(home)
}
})
test('--superpowers-from accepts a path with spaces', () => {
const home = tempDir()
try {
const src = makeSuperpowers(join(home, 'super powers'))
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
} finally {
cleanup(home)
}
})
test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => {
const home = tempDir()
try {
const fromFlag = makeSuperpowers(join(home, 'flag-source'), 'from-flag')
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], {
DSH_SUPERPOWERS_SOURCE: fromEnv,
})
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-flag/,
)
} finally {
cleanup(home)
}
})
test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
const home = tempDir()
try {
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv })
assert.equal(run.status, 0, run.stdout + run.stderr)
assert.match(
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
/from-env/,
)
} finally {
cleanup(home)
}
})
test('--skills fails when there is no source and nothing installed', () => {
const home = tempDir()
try {
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.match(out, /git clone --depth 1 https:\/\/github\.com\/obra\/superpowers/)
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)
} finally {
cleanup(home)
}
})
test('--skills keeps installed copies when the source is gone', () => {
const home = tempDir()
try {
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
write(installed, '---\nname: using-superpowers\n---\ninstalled\n')
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /WARN: no superpowers source/)
assert.match(readFileSync(installed, 'utf8'), /installed/)
} finally {
cleanup(home)
}
})
test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => {
const home = tempDir()
try {
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: '' })
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/)
assert.doesNotMatch(out, /explicit superpowers source/)
} finally {
cleanup(home)
}
})
test('an invalid explicit source fails without touching the legacy cache', () => {
const home = tempDir()
try {
makeLegacyCache(home)
const empty = join(home, 'not-superpowers')
const run = runInstaller(home, ['--skills', '--superpowers-from', empty])
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: explicit superpowers source .* has no using-superpowers\/SKILL\.md/)
assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false)
} finally {
cleanup(home)
}
})
test('the legacy plugin cache still works and warns', () => {
const home = tempDir()
try {
makeLegacyCache(home)
const run = runInstaller(home, ['--skills'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, /WARN: legacy superpowers source/)
assert.match(run.stdout, /copied 2 superpowers skills/)
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
} finally {
cleanup(home)
}
})
test('--check reports the pinned source', () => {
const home = tempDir()
try {
const pinned = join(home, '.dsh', 'superpowers')
makeSuperpowers(pinned)
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\)/)
} finally {
cleanup(home)
}
})
test('--check fails on an invalid explicit source', () => {
const home = tempDir()
try {
const empty = join(home, 'not-superpowers')
const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: empty })
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /FAIL: explicit superpowers source/)
} finally {
cleanup(home)
}
})

View File

@ -84,7 +84,10 @@ Issue: #49. Спека: `openspec/changes/dsh-install-source/specs/dsh-install/s
`--check` — диагностика, а не установка: отсутствие superpowers-источника
остаётся `WARN`, код возврата меняют только жёсткие `FAIL` (python3, git, node,
хуки, бандл). Так же уже сделано для openspec (#42, R-4).
хуки, бандл). Так же уже сделано для openspec (#42, R-4). Исключение —
невалидный **явный** источник (`--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`):
это ошибка конфигурации пользователя, `--check` даёт `FAIL`, иначе опечатка
выглядит как «источника нет».
## 3. Формулировки, которые попадут в установленный AGENTS.md

View File

@ -84,7 +84,8 @@ SHALL оставаться fallback'ом для машин, где `--skills --r
источник, но `--skills` печатает `WARN: legacy superpowers source` с
подсказкой перейти на пиннинг/флаг.
- `--check`: legacy-источник — `WARN`, явный/пиннинг — `ok`, отсутствие —
`WARN` с подсказкой.
`WARN` с подсказкой, невалидный явный источник — `FAIL` и код 1
(пользователь задал его сам, подсказка «нет источника» здесь неверна).
- Runtime: bootstrap и резолвер ролей сохраняют порядок `~/.dsh` → `~/.claude`,
но при выборе legacy-пути один раз за процесс пишут в лог предупреждение.
- Удаление fallback'ов — отдельное изменение; в этом change они не удаляются.