diff --git a/dsh/README.md b/dsh/README.md index 0b0c0cc..934d84f 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -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 /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. diff --git a/dsh/install.sh b/dsh/install.sh index 093cc16..1208114 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -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 ] # 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 < - --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 "\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 + 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 /skills/... or /...)" >&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 ; 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 diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index c04e940..29ea9e8 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -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) + } +}) diff --git a/openspec/changes/dsh-install-source/design.md b/openspec/changes/dsh-install-source/design.md index 7c579a0..b5dde46 100644 --- a/openspec/changes/dsh-install-source/design.md +++ b/openspec/changes/dsh-install-source/design.md @@ -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 diff --git a/openspec/changes/dsh-install-source/specs/dsh-install/spec.md b/openspec/changes/dsh-install-source/specs/dsh-install/spec.md index 3ebc627..57ad9a8 100644 --- a/openspec/changes/dsh-install-source/specs/dsh-install/spec.md +++ b/openspec/changes/dsh-install-source/specs/dsh-install/spec.md @@ -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 они не удаляются.