feat(dsh): install pulls superpowers from git (#67)
This commit is contained in:
parent
c591083b37
commit
b292a38818
164
dsh/install.sh
164
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 <<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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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`; рабочее дерево управляемое, локальные правки
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user