Compare commits

..

5 Commits

9 changed files with 909 additions and 61 deletions

View File

@ -0,0 +1,223 @@
# Install pulls superpowers from git — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `dsh/install.sh --skills` сам клонирует и обновляет superpowers из git, оставляя явные источники и офлайн-кэш рабочими.
**Architecture:** В `install.sh` появляется git-шаг: клон `$DSH_HOME/superpowers` из `$DSH_SUPERPOWERS_REPO` (по умолчанию GitHub) или `fetch --depth 1` + `reset --hard FETCH_HEAD` для существующего клона. Резолвер для `--check` отдельный и офлайновый. Тесты используют локальный `file://`-репозиторий.
**Tech Stack:** bash (`set -euo pipefail`), git CLI, Node.js ESM (node:test), OpenSpec.
**Spec:** `openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md` (дизайн — `design.md`).
## Global Constraints
- Порядок источников: `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → git (`$DSH_HOME/superpowers`) → legacy-кэш CC → `~/.claude/skills`.
- Явный валидный источник MUST NOT запускать git и не создавать `$DSH_HOME/superpowers`.
- Репозиторий: `$DSH_SUPERPOWERS_REPO`, default `https://github.com/obra/superpowers`; глубина 1; обновление `fetch --depth 1 origin` + `reset --hard FETCH_HEAD`.
- Фактический вывод `--skills` для git: `(source: <dir>, git <short-sha>)`.
- `--check` MUST NOT клонировать/обновлять; показывает `ok: superpowers source (<dir>, git <sha>)`.
- Неудачный первый клон MUST NOT оставлять каталог: `rm -rf $DSH_HOME/superpowers`.
- Ручная копия `$DSH_HOME/superpowers` без `.git` — `pinned`, без обновления, с `WARN`.
- Тесты не выходят в сеть: `installerEnv` задаёт недоступный `DSH_SUPERPOWERS_REPO`, git-тесты — `file://`-фикстуру.
- Маркировка: `T<n>.<k>` (ревью задачи), `C/I/M` (ревью ветки), `R-n`/`O-n` (рулинги/наблюдения).
## Review Focus
- Клон падает на середине: каталог не остаётся, следующий запуск клонирует заново.
- Офлайн при существующем клоне: `WARN`, копии из кэша, код 0 (не FAIL).
- Ручная копия без `.git`: используется, `WARN` об отсутствии обновления, копии на месте.
- Явный источник при пустом `DSH_HOME`: git не вызывается, `$DSH_HOME/superpowers` не появляется.
- `--check` на пустом HOME с недоступным репозиторием: `WARN`, код 0, клон не создан.
- Путь `$DSH_HOME` с пробелами: клон в кавычках, sha/копирование работают.
---
### Task 1: git-источник в `--skills`
**Files:**
- Modify: `dsh/install.sh` (`CLONE_HINT`, `git_superpowers_source`, `superpowers_source`, `install_skills`, `usage`)
- Modify: `dsh/tests/install.test.mjs`
**Interfaces:**
- Produces:
- `git_superpowers_source() -> rc 0` и stdout `<skills-dir>\tgit:<sha>`; `rc 1` без источника (печатает `WARN`/`FAIL` причину);
- `superpowers_source()` — прежний контракт `<dir>\t<explicit|pinned|legacy|git:<sha>>`;
- `install_skills` печатает `copied N superpowers skills into <dest> (source: <dir>, git <sha>)` для git и `(source: <dir>)` иначе.
- Consumes: `normalize_superpowers_dir`, `$DSH_HOME`, `$DSH_SUPERPOWERS_REPO`.
- [ ] **Step 1: Тестовый хелпер и падающие тесты**
В `dsh/tests/install.test.mjs` импортировать `mkdirSync` из `node:fs`, `git` из `./helpers.mjs`, `pathToFileURL` из `node:url`; добавить:
```js
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
}
```
`installerEnv` дополнительно: `delete env.DSH_SUPERPOWERS_REPO` и
`env.DSH_SUPERPOWERS_REPO = join(home, 'no-superpowers-repo')` (недоступный путь — сеть в тестах не нужна). Git-тесты переопределяют его через `file://`-URL.
Тесты:
```js
test('--skills clones the git source on first run', ...)
// home; repo = makeSuperpowersRepo(home); env repo = pathToFileURL(repo).href;
// run --skills; status 0; <dshHome>/superpowers/.git существует;
// stdout /copied 2 superpowers skills/ и new RegExp(`git ${git(repo,['rev-parse','--short','HEAD'])}`);
// skills/using-superpowers на месте
test('--skills updates an existing clone to the new commit', ...)
// после первого прогона: write(join(repo,'skills','new-skill','SKILL.md'), ...);
// git add/commit; повторить --skills; status 0;
// <dshHome>/skills/new-skill/SKILL.md существует; stdout содержит новый short-sha
test('an offline update keeps the cached clone', ...)
// после клона: env repo = join(home,'gone'); run --skills; status 0;
// stdout/stderr /WARN: cannot update superpowers/; /copied 2 superpowers skills/
test('a failed first clone leaves no checkout', ...)
// пустой home (repo недоступен); run --skills; status 1;
// <dshHome>/superpowers НЕ существует; FAIL упоминает no-superpowers-repo и --superpowers-from
test('an explicit source never touches git', ...)
// --skills --superpowers-from <fixture>; status 0; <dshHome>/superpowers НЕ существует
test('a manual non-git checkout is used but not updated', ...)
// write <dshHome>/superpowers/skills/using-superpowers/SKILL.md; repo недоступен;
// run --skills; status 0; stderr /not a git checkout/; /copied 1 superpowers skills/
```
- [ ] **Step 2: Прогнать, увидеть падение**
Run: `node --test dsh/tests/install.test.mjs`
Expected: новые тесты падают (нет клона/обновления/сообщений).
- [ ] **Step 3: Реализовать в `dsh/install.sh`**
- `SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"` рядом с `CLONE_HINT`; `CLONE_HINT` переписать на `git clone --depth 1 "$SUPERPOWERS_REPO" "$DSH_HOME/superpowers"`.
- `git_superpowers_source` по описанию из Interfaces: git-клон → fetch+reset → `normalize_superpowers_dir`; на неудачном клоне `rm -rf "$DSH_HOME/superpowers"` и `return 1`; на неудачном fetch — `WARN` и работа из кэша.
- `superpowers_source`: после явных источников вызвать `git_superpowers_source`; её успех вернуть как есть, `rc 1` — продолжить на legacy-кэш и `~/.claude/skills`.
- `install_skills`: разобрать kind `git:<sha>` → в итоговой строке `(source: $source, git $sha)`; `FAIL`-сообщение — репозиторий и `--superpowers-from` вместо ручного клона.
- `usage`: пункт 3 — git-клон `$SUPERPOWERS_REPO` → `$DSH_HOME/superpowers`, обновляется при каждом `--skills`.
- [ ] **Step 4: Прогнать тесты**
Run: `node --test dsh/tests/install.test.mjs`
Expected: PASS всех тестов файла.
- [ ] **Step 5: Commit**
```bash
git add dsh/install.sh dsh/tests/install.test.mjs
git commit -m "feat(dsh): install pulls superpowers from git (#67)"
```
---
### Task 2: офлайновый `--check` с git-версией
**Files:**
- Modify: `dsh/install.sh` (`static_superpowers_source`, блок `check`)
- Modify: `dsh/tests/install.test.mjs`
**Interfaces:**
- Produces: `static_superpowers_source()` — тот же формат, но без сети: явные источники → существующий git-клон (`git:<sha>`) → ручная копия (`pinned`) → legacy; `--check` печатает `ok: superpowers source (<dir>, git <sha>)`, `WARN` для `pinned`/legacy/отсутствия, `FAIL` для невалидного явного.
- [ ] **Step 1: Падающие тесты**
```js
test('--check reports the git sha and does not clone', ...)
// сначала --skills с file://-репозиторием (клон создан);
// затем env repo = недоступный путь; run --check;
// stdout /ok: superpowers source \(.*superpowers\/skills, git [0-9a-f]+\)/; status 0
test('--check never clones on an empty HOME', ...)
// empty home, repo недоступен; run --check; status 0;
// <dshHome>/superpowers НЕ существует; stderr /WARN: no superpowers source/
test('--check warns about a manual non-git checkout', ...)
// write <dshHome>/superpowers/skills/using-superpowers/SKILL.md;
// run --check; stdout/stderr /not a git checkout/
```
Существующий тест `--check reports the pinned source` заменить этим кейсом (ручная копия больше не `ok`), сценарий спеки #49 обновлён в change.
- [ ] **Step 2: Прогнать, увидеть падение**
Run: `node --test dsh/tests/install.test.mjs`
Expected: FAIL на новых и переименованном тестах.
- [ ] **Step 3: Реализовать**
- Вынести `static_superpowers_source` (без вызова git-команд, меняющих состояние; `rev-parse --short HEAD` — чтение).
- `check()` переключить с `superpowers_source` на `static_superpowers_source`; разобрать kind: `git:<sha>` → `ok: superpowers source ($src, git $sha)`, `pinned` → `WARN: ... is not a git checkout; --skills will not update it`, `legacy` → `WARN`, нет — `WARN` с `$SUPERPOWERS_REPO` и `--superpowers-from`.
- `--check` не должен создавать `$DSH_HOME/superpowers` и не вызывать `git clone`/`fetch`.
- [ ] **Step 4: Прогнать тесты**
Run: `bash dsh/tests/run.sh`
Expected: PASS всего набора.
- [ ] **Step 5: Commit**
```bash
git add dsh/install.sh dsh/tests/install.test.mjs
git commit -m "feat(dsh): offline --check reports the git source (#67)"
```
---
### Task 3: документация, приёмка, PR
**Files:**
- Modify: `dsh/README.md` (Quick start, Known limits, config)
- Modify: `dsh/PORT.md` (§5 install, §7 risks, §8 phases)
- Modify: `openspec/changes/dsh-superpowers-git/design.md` (раздел приёмки)
- [ ] **Step 1: README/PORT**
Quick start: убрать ручной `git clone`, оставить `bash dsh/install.sh --global --skills --roles`; описать `DSH_SUPERPOWERS_REPO`, офлайн-кэш и `--superpowers-from`. Known limits: свежий апстрим тянется при каждом `--skills`; ручная копия без `.git` не обновляется. PORT §5: git-источник и sha; §7: сеть в установке и откат на `--superpowers-from`; §8: #67.
- [ ] **Step 2: Полный прогон и приёмка**
```bash
bash dsh/tests/run.sh
bash tests/run.sh
openspec validate dsh-superpowers-git --strict
```
Живой прогон на машине владельца (после merge): `bash dsh/install.sh --skills`
→ клон GitHub, `--check` → `git <sha>`, повторный `--skills` → обновление.
В design.md записать `### Приёмка: прогон <дата>` с результатами.
- [ ] **Step 3: Commit, push, PR**
```bash
git add -A
git commit -m "docs(dsh): git source для superpowers (#67)"
git push -u origin feat/67-dsh-superpowers-git
tea pulls create --head feat/67-dsh-superpowers-git --base main \
--title "DSH: install сам тянет свежий superpowers с git (#67)" \
--description-file <подготовленный файл>
```
- [ ] **Step 4: Ревью ветки и итог**
Свежий ревьюер (`deepseek-v4-pro`) по `review-package`; Critical/Important → один fix pass по TDD; Minor → ledger и PR; итоговая таблица маркеров.
## Acceptance
- Спека `dsh-install` (delta) покрыта тестами: клон, обновление, офлайн-кэш, неудачный клон, явный источник без сети, ручная копия, `--check` без сети и с sha.
- `bash dsh/tests/run.sh`, `bash tests/run.sh`, `openspec validate --strict` — зелёные.
- Тесты не обращаются к GitHub: единственный источник — `file://`-фикстура или недоступный путь.
- После merge: `--skills` на машине владельца тянет GitHub и убирает `WARN: legacy superpowers source`.

View File

@ -210,11 +210,14 @@ with "requires ... in the Host scope".
real files there; the Claude Code locations are a legacy fallback and the real files there; the Claude Code locations are a legacy fallback and the
bundle logs one warning per process when it resolves one of them. bundle logs one warning per process when it resolves one of them.
Install-time source resolution is separate and stricter (change Install-time source resolution is separate and stricter (changes
`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → `dsh-install-source` and `dsh-superpowers-git`): `--superpowers-from` →
`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An `$DSH_SUPERPOWERS_SOURCE` → the managed git checkout `$DSH_HOME/superpowers`
invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a (clone or `fetch`+`reset` from `$DSH_SUPERPOWERS_REPO`) → the legacy cache →
fresh install and a `WARN` when `~/.dsh/skills` already holds the skills. `~/.claude/skills`. An invalid explicit source is a hard `FAIL`; a failed git
update reuses the cached checkout (`WARN`); no source at all is a `FAIL` on a
fresh install and a `WARN` when `~/.dsh/skills` already holds the skills. The
git sha of the checkout is printed by `--skills` and by the offline `--check`.
### OpenSpec skills ### OpenSpec skills
@ -243,20 +246,21 @@ openspec archive <id> -y
## 6. Install and verify ## 6. Install and verify
Prerequisites: the `claude-harness` checkout, a superpowers source, DSH Prerequisites: the `claude-harness` checkout, DSH 0.2.1-rc.3, `python3`, `git`,
0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the Node, and network access for the superpowers checkout. Claude Code is not
superpowers source once with required at all: `--skills` clones `https://github.com/obra/superpowers` into
`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`, `~/.dsh/superpowers` on the first run and updates it on later runs
then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this (`DSH_SUPERPOWERS_REPO` for a mirror); `--superpowers-from` /
checkout. `DSH_SUPERPOWERS_SOURCE` skip the network. `--roles` copies roles; the hooks
come from this checkout.
```bash ```bash
# 1. Static checks # 1. Static checks
bash dsh/install.sh --check bash dsh/install.sh --check
# 2. Global instructions, skills, and roles (run from a terminal; writes # 2. Global instructions, skills, and roles (run from a terminal; writes
# outside the session workspace). The superpowers source comes from # outside the session workspace). --skills clones/updates ~/.dsh/superpowers;
# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers. # --superpowers-from or DSH_SUPERPOWERS_SOURCE skip the network.
bash dsh/install.sh --global bash dsh/install.sh --global
bash dsh/install.sh --skills # or: --skills --superpowers-from DIR bash dsh/install.sh --skills # or: --skills --superpowers-from DIR
bash dsh/install.sh --roles bash dsh/install.sh --roles
@ -361,6 +365,11 @@ profile was removed; the real `desktop` profile was not touched.
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. The port uses two - **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. The port uses two
live routes (`deepseek-flash`, `deepseek-v4-pro`) from the opt-in overlay in live routes (`deepseek-flash`, `deepseek-v4-pro`) from the opt-in overlay in
`dsh/subagent-models.yml`; without it every dispatch runs on the session model. `dsh/subagent-models.yml`; without it every dispatch runs on the session model.
- **Install needs network (or a source).** `--skills` clones/fetches from
`DSH_SUPERPOWERS_REPO`; on an air-gapped or rate-limited machine use a mirror
or `--superpowers-from`. The checkout is shallow and updated by
`fetch`+`reset --hard`, so local edits inside it are lost; a fresh upstream
commit is taken as-is, and `--superpowers-from` pins a known-good copy.
- **`~/.claude` dependence.** No longer needed for install, bootstrap, or - **`~/.claude` dependence.** No longer needed for install, bootstrap, or
roles: the install-time source is explicit, and `--skills --roles` copy real roles: the install-time source is explicit, and `--skills --roles` copy real
files into `~/.dsh`. The Claude Code locations remain a **legacy migration files into `~/.dsh`. The Claude Code locations remain a **legacy migration
@ -398,3 +407,6 @@ profile was removed; the real `desktop` profile was not touched.
`--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`/`~/.dsh/superpowers`, hard `--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`/`~/.dsh/superpowers`, hard
`FAIL` instead of a silent empty bootstrap, DSH-owned role paths in the `FAIL` instead of a silent empty bootstrap, DSH-owned role paths in the
installed `AGENTS.md`, legacy warnings at install and runtime. installed `AGENTS.md`, legacy warnings at install and runtime.
10. Install pulls superpowers from git — **done** (change `dsh-superpowers-git`):
clone/update `$DSH_HOME/superpowers` from `DSH_SUPERPOWERS_REPO`, `--check`
stays offline and reports the sha, explicit sources skip the network.

View File

@ -33,19 +33,19 @@ risks.
# Static check # Static check
bash dsh/install.sh --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 # Global instructions, superpowers skills, local role skill, and roles
# (run from a terminal; these write outside the session workspace) # (run from a terminal; these write outside the session workspace).
# --skills clones https://github.com/obra/superpowers into ~/.dsh/superpowers
# on the first run and updates that checkout on every later run.
bash dsh/install.sh --global bash dsh/install.sh --global
bash dsh/install.sh --skills bash dsh/install.sh --skills
bash dsh/install.sh --roles bash dsh/install.sh --roles
# Offline, mirror, or pinned version: skip the network entirely.
bash dsh/install.sh --skills --superpowers-from /path/to/superpowers
# or: DSH_SUPERPOWERS_SOURCE=/path/to/superpowers bash dsh/install.sh --skills
# or: DSH_SUPERPOWERS_REPO=https://mirror/superpowers.git bash dsh/install.sh --skills
# В корне проекта, где уже есть openspec/: добавить agents-скиллы # В корне проекта, где уже есть openspec/: добавить agents-скиллы
bash <harness>/dsh/install.sh --openspec bash <harness>/dsh/install.sh --openspec
@ -55,10 +55,11 @@ bash <harness>/dsh/install.sh --openspec
``` ```
`--skills` resolves superpowers in this order: `--superpowers-from` → `--skills` resolves superpowers in this order: `--superpowers-from` →
`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache `$DSH_SUPERPOWERS_SOURCE` → the git checkout `~/.dsh/superpowers` (clone or
(legacy, warns). A clean HOME with no source and no installed copy fails with `fetch`+`reset` from `$DSH_SUPERPOWERS_REPO`) → the Claude Code plugin cache
the clone command above instead of pretending success; the local `role` and (legacy, warns). With no network and no other source, an existing checkout is
`sdd` skills are copied either way. reused with a warning and a fresh install fails 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 Do not hand-edit `$DSH_HOME/profiles/desktop/package.json` or
`cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin `cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin
@ -156,11 +157,13 @@ repository layout:
bundle needs a real copy of `hooks/` (already listed in `package.json` bundle needs a real copy of `hooks/` (already listed in `package.json`
`files`). `files`).
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The - `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The
install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or the
`~/.dsh/superpowers`; Claude Code locations are a **legacy migration managed git checkout `~/.dsh/superpowers` (`DSH_SUPERPOWERS_REPO`, default
fallback** and log one warning per process when used, so a partial Claude GitHub), which `--skills` clones/updates and reports as `git <sha>`. `--check`
Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or is offline and reports that sha. A manual copy of `~/.dsh/superpowers` without
`DSH_SUPERPOWERS_SKILL` for another layout. `.git` is used as-is and never updated. Claude Code locations are a **legacy
migration fallback** and log one warning per process when used. Set
`bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for another layout.
- Roles resolve `~/.dsh/roles` first; `~/.claude/roles` and the repository - Roles resolve `~/.dsh/roles` first; `~/.claude/roles` and the repository
`roles/` are fallbacks. After `--roles` the runtime never needs `~/.claude`. `roles/` are fallbacks. After `--roles` the runtime never needs `~/.claude`.
- Role loading is model-driven through the `role` skill; there is no automatic - Role loading is model-driven through the `role` skill; there is no automatic

View File

@ -29,7 +29,8 @@ DO_ROLES=0
DO_OPENSPEC=0 DO_OPENSPEC=0
SUPERPOWERS_FROM="" SUPERPOWERS_FROM=""
SUPERPOWERS_FROM_SET=0 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() { usage() {
cat <<EOF 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 --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-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 1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE 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) 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: Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE Plugins -> install a bundle from $BUNDLE
@ -110,8 +114,62 @@ normalize_superpowers_dir() {
return 1 return 1
} }
# Print "<skills-dir>\t<explicit|pinned|legacy>". Returns 2 when an explicitly # Update (or create) the managed git checkout and print "<dir>\tgit:<sha>".
# configured source is invalid (never fall back silently), 1 when none is found. # 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() { superpowers_source() {
local dir legacy local dir legacy
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
@ -124,8 +182,7 @@ superpowers_source() {
printf '%s\texplicit\n' "$dir" printf '%s\texplicit\n' "$dir"
return 0 return 0
fi fi
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then if git_superpowers_source; then
printf '%s\tpinned\n' "$dir"
return 0 return 0
fi fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" 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 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. # Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() { explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
@ -218,8 +314,9 @@ check() {
else else
echo "WARN: .agents/skills missing; run --openspec in the project" >&2 echo "WARN: .agents/skills missing; run --openspec in the project" >&2
fi fi
local src="" rc=0 kind local src="" rc=0 kind sha
src="$(superpowers_source)" || rc=$? # --check must stay offline: this resolver reads the existing checkout only.
src="$(static_superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source report_invalid_explicit_source
failed=1 failed=1
@ -227,16 +324,29 @@ check() {
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; 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 echo "WARN: no superpowers source; installed skills in $DSH_HOME/skills are used" >&2
else else
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2 echo "WARN: no superpowers source; run --skills to clone $SUPERPOWERS_REPO" >&2
fi fi
else else
kind="${src##*$'\t'}" kind="${src##*$'\t'}"
src="${src%%$'\t'*}" src="${src%%$'\t'*}"
if [ "$kind" = legacy ]; then case "$kind" in
echo "WARN: legacy superpowers source ($src); prefer --superpowers-from or $DSH_HOME/superpowers" >&2 legacy)
else echo "WARN: legacy superpowers source ($src); prefer a git checkout or --superpowers-from" >&2
echo "ok: superpowers source ($src)" ;;
fi 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 fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "ok: dsh skills ($DSH_HOME/skills)" echo "ok: dsh skills ($DSH_HOME/skills)"
@ -310,19 +420,25 @@ install_skills() {
if [ "$rc" -ne 0 ]; then if [ "$rc" -ne 0 ]; then
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; 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: 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 return 0
fi fi
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2 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: git source $SUPERPOWERS_REPO is unavailable; pass --superpowers-from <dir> or set DSH_SUPERPOWERS_REPO" >&2
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2 echo "FAIL: local skills (role, sdd) are installed by this run" >&2
return 1 return 1
fi fi
kind="${line##*$'\t'}" kind="${line##*$'\t'}"
source="${line%%$'\t'*}" source="${line%%$'\t'*}"
if [ "$kind" = legacy ]; then local sha=""
echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2 case "$kind" in
fi legacy)
echo "WARN: legacy superpowers source $source; prefer a git checkout or --superpowers-from" >&2
;;
git:*)
sha="${kind#git:}"
;;
esac
local dir name dest local dir name dest
for dir in "$source"/*; do for dir in "$source"/*; do
[ -f "$dir/SKILL.md" ] || continue [ -f "$dir/SKILL.md" ] || continue
@ -332,7 +448,11 @@ install_skills() {
cp -R "$dir" "$dest" cp -R "$dir" "$dest"
count=$((count + 1)) count=$((count + 1))
done 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() { install_local_skills() {

View File

@ -1,10 +1,10 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process' 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 { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath, pathToFileURL } from 'node:url'
import { cleanup, tempDir, write } from './helpers.mjs' import { cleanup, git, tempDir, write } from './helpers.mjs'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
const script = join(here, '..', 'install.sh') 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, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\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 run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
const first = run() const first = run()
@ -93,9 +98,29 @@ function makeLegacyCache(home) {
function installerEnv(home) { function installerEnv(home) {
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') } const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
delete env.DSH_SUPERPOWERS_SOURCE 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 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 = {}) { function runInstaller(home, args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], { return spawnSync('bash', [script, ...args], {
env: { ...installerEnv(home), ...extraEnv }, 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 const out = run.stdout + run.stderr
assert.equal(run.status, 1, out) assert.equal(run.status, 1, out)
assert.match(out, /FAIL: no superpowers source/) 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', 'role', 'SKILL.md')), 'local skills still installed')
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', '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) 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() const home = tempDir()
try { try {
const pinned = join(home, '.dsh', 'superpowers') makeSuperpowers(join(home, '.dsh', 'superpowers'))
makeSuperpowers(pinned)
const run = runInstaller(home, ['--check']) const run = runInstaller(home, ['--check'])
const out = run.stdout + run.stderr const out = run.stdout + run.stderr
assert.equal(run.status, 0, out) 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 { } finally {
cleanup(home) cleanup(home)
} }
@ -344,3 +369,126 @@ test('an empty --superpowers-from is rejected, not ignored', () => {
cleanup(home) 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)
}
})
test('--check reports the git sha of the managed checkout', () => {
const home = tempDir()
try {
const repo = makeSuperpowersRepo(home)
const sha = git(repo, ['rev-parse', '--short', 'HEAD'])
assert.equal(runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) }).status, 0)
const run = runInstaller(home, ['--check'])
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.match(out, new RegExp(`ok: superpowers source \\(.*superpowers/skills, git ${sha}\\)`))
} finally {
cleanup(home)
}
})
test('--check never clones on an empty HOME', () => {
const home = tempDir()
try {
const repo = makeSuperpowersRepo(home)
const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: gitUrl(repo) })
const out = run.stdout + run.stderr
assert.equal(run.status, 0, out)
assert.equal(existsSync(join(home, '.dsh', 'superpowers')), false, '--check must not clone')
assert.match(out, /WARN: no superpowers source/)
} finally {
cleanup(home)
}
})

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@ -0,0 +1,124 @@
# Design: install тянет superpowers с git
Issue: #67. Спека: `openspec/changes/dsh-superpowers-git/specs/dsh-install/spec.md`.
Контекст: #49 (`dsh-install-source`) — источник был явным, сеть в установке
запрещена; это решение пересматривается по команде владельца 24.10.2026.
## 1. Что меняется в контракте #49
| | #49 | #67 |
|---|---|---|
| Источник по умолчанию | ручной клон/флаг | git: клон в `$DSH_HOME/superpowers` |
| Обновление | нет | `fetch --depth 1` + `reset --hard FETCH_HEAD` при каждом `--skills` |
| Сеть в установке | запрещена | разрешена только для git-источника |
| Явный источник | главнее | главнее, сеть не трогает |
| `--check` | офлайн | офлайн + git sha |
| Офлайн-машина | явный источник | кэш клона (WARN) → legacy (WARN) → FAIL |
## 2. Решения
### D-7 (решение владельца). Git — источник по умолчанию
`--skills` без флага/переменной клонирует репозиторий и обновляет его при
следующих запусках. Обоснование владельца: ручной клон не обновляется и
оставляет `WARN: legacy superpowers source` навсегда. Цена: установка требует
сеть (кроме явного источника и кэша), а свежий апстрим может принести
несовместимость — она лечится `--superpowers-from` на любой коммит.
### D-8. Явный источник полностью отключает сеть
`--superpowers-from` и `$DSH_SUPERPOWERS_SOURCE` проверяются первыми и, если
валидны, git не вызывается вообще. Это и офлайн-путь, и отладка, и пиннинг на
конкретную версию. Цена: две ветки кода, покрыты тестами, включая «явный
источник не создаёт `$DSH_HOME/superpowers`».
### D-9. Офлайн не ломает установку, если есть чем ставить
Порядок при недоступном git: существующий клон (в т.ч. с неудачным fetch) →
legacy-кэш CC → `~/.claude/skills` → FAIL. Первые два шага печатают `WARN` с
причиной (репозиторий), чтобы деградация была видна, но машина без сети
продолжала работать. Цена: опечатка в `DSH_SUPERPOWERS_REPO` может привести к
legacy-источнику — сообщение называет репозиторий и путь кэша.
### D-10. Ручная копия без `.git` — пиннинг, а не ошибка
Если `$DSH_HOME/superpowers` существует, валиден, но не является git-клоном,
он используется как есть с `WARN`, что обновляться не будет. Это совместимость
с #49: у кого уже лежит ручная копия, после обновления обвязки установка не
падает. Цена: такой каталог стареет молча — предупреждение на каждом `--skills`.
### D-11. `--check` остаётся офлайновым
`--check` — диагностика; клонировать или обновлять из него нельзя (в #42 уже
принято: `--check` не должен иметь побочных эффектов). Он показывает
`ok: superpowers source (<path>, git <sha>)` для клона, `WARN` для ручной копии
и для legacy, и подсказку про `--skills`/`DSH_SUPERPOWERS_REPO`, когда клона нет.
Цена: `--check` не скажет, доступен ли репозиторий; это выясняется при установке.
### D-12. Тесты идут через локальный `file://`-репозиторий
`DSH_SUPERPOWERS_REPO` — не только фича для зеркал, но и тестовый шов: набор не
ходит в GitHub. Тестовый хелпер создаёт git-репозиторий с `skills/*/SKILL.md`,
запускает `--skills` с `DSH_SUPERPOWERS_REPO=file://<путь>`, затем добавляет
коммит и проверяет обновление копий. Офлайн-кейс — репозиторий, которого нет.
## 3. Решения, принятые без обсуждения
- **2026-10-24. `--depth 1` и `reset --hard FETCH_HEAD`, без тегов и подписей.**
«Свежая версия» = последний коммит default-ветки; глубина 1 экономит сеть,
`reset --hard` гарантирует чистое управляемое дерево. Цена: локальные правки в
`~/.dsh/superpowers` теряются, подлинность апстрима не проверяется (TLS и
доверие к GitHub); откат — `--superpowers-from` на известный коммит.
- **2026-10-24. Неудачный первый клон удаляет каталог.**
`rm -rf $DSH_HOME/superpowers` после неудачи, чтобы следующий запуск снова
клонировал, а не принимал половину за кэш. Цена: если пользователь положил
туда что-то своё и не было `.git` — этот путь не трогается (D-10), удаление
только для созданного нами каталога в текущем запуске.
- **2026-10-24. Отдельного флага `--offline` нет.**
Офлайн покрыт существующим клоном (D-9) и явным источником (D-8). Цена: нет
способа гарантированно запретить сеть, кроме явного источника; для установки
это приемлемо.
- **2026-10-24. `DSH_SUPERPOWERS_REPO` виден и в `--check`.**
В `WARN`/`FAIL` печатается фактический репозиторий, чтобы зеркало и опечатка
различались. Цена: строка вывода зависит от env — приемлемо.
- **2026-10-24. `tasks.md` OpenSpec не заполняется** (как в прошлых change):
задачи в плане superpowers.
## 4. Риски
- **Свежий апстрим ломает установку.** `--skills` тянет последний коммит; если
superpowers переименует `skills/using-superpowers`, `FAIL` скажет, что искать.
Обход — `--superpowers-from` с проверенной копией; sha в выводе помогает
зафиксировать версию.
- **Сеть в установке.** Машины без доступа к GitHub должны использовать
`DSH_SUPERPOWERS_REPO` (зеркало) или `--superpowers-from`; ошибка клона
называет репозиторий и оба пути.
- **Тесты и сеть.** Любой тест, забывший выставить `DSH_SUPERPOWERS_REPO`, уйдёт
в GitHub. Поэтому `installerEnv` в тестах задаёт недоступный repo по
умолчанию, а тесты git-источника — `file://`-фикстуру; проверяется, что
`--check` не создаёт клон.
- **Офлайн-деградация незаметна.** WARN есть на каждом шаге деградации; `--check`
показывает, какой источник будет использован при следующем `--skills`.
- **Совместимость с #49.** Ручная копия без `.git` и legacy-кэш остаются
рабочими; тесты #49 переиспользуются, меняются только ожидания сообщений.
## 5. Приёмка
- `openspec validate dsh-superpowers-git --strict` — change валиден.
- `bash dsh/tests/run.sh` — зелёный, включая клон/обновление/офлайн/`--check`.
- `bash tests/run.sh` — регрессий в CC-контуре нет.
- Живой прогон на машине владельца: `--skills` клонирует GitHub-репозиторий,
`--check` показывает `git <sha>`, повторный `--skills` обновляет.
### Приёмка: прогон 24.10.2026
- `openspec validate dsh-superpowers-git --strict` — change валиден.
- `bash dsh/tests/run.sh` — 71/71 pass; `bash tests/run.sh` — см. PR.
- Живой клон GitHub в temp HOME без `~/.claude`: `--skills` → 15
superpowers-скиллов, `source: .../.dsh/superpowers/skills, git 2ce8aa1`;
повторный `--skills` → обновление того же клона, снова `git 2ce8aa1`;
`--check` → `ok: superpowers source (.../.dsh/superpowers/skills, git 2ce8aa1)`.
- Тесты сети не касаются: `installerEnv` подставляет недоступный
`DSH_SUPERPOWERS_REPO`, git-кейсы — локальный `file://`-репозиторий; мутацией
проверено, что `--check never clones on an empty HOME` падает, если вернуть
`--check` на сетевой резолвер.

View File

@ -0,0 +1,52 @@
# Proposal: install сам тянет свежий superpowers с git
Issue: #67. Связано: #49 (закрыт), #48 (роадмап), change `dsh-install-source`.
Ответ владельца 24.10.2026: «а давай инстал будет тащить сам свежую версию с
гита».
## Why
После #49 install-time источник задаётся явно: `--superpowers-from`,
`$DSH_SUPERPOWERS_SOURCE` или ручной клон в `~/.dsh/superpowers`. Это снимает
зависимость от `~/.claude`, но перекладывает получение источника на человека и
не обновляет его: клон, сделанный полгода назад, остаётся «свежим» навсегда, а
`WARN: legacy superpowers source` на машине владельца так и висит, потому что
пиннинга нет.
Владелец хочет, чтобы `dsh/install.sh --skills` сам получал свежий superpowers:
первый запуск — клон, следующие — обновление. Явные флаги остаются для офлайна,
зеркал и отладки и сеть не трогают.
## What Changes
- **git — источник по умолчанию.** `--skills` клонирует
`https://github.com/obra/superpowers` в `$DSH_HOME/superpowers` (depth 1), если
клона нет, и обновляет `fetch --depth 1` + `reset --hard FETCH_HEAD`, если
есть. URL переопределяется `$DSH_SUPERPOWERS_REPO` (зеркала, локальные
репозитории в тестах).
- **Порядок источников:** явный источник (`--superpowers-from`,
`$DSH_SUPERPOWERS_SOURCE`) → git-клон (обновить/создать) → legacy-кэш Claude
Code → legacy `~/.claude/skills`. Ручная копия `~/.dsh/superpowers` без `.git`
используется как есть с `WARN` (совместимость с #49).
- **Сеть и ошибки.** Нет сети при существующем клоне — `WARN`, работа из кэша.
Нет сети, нет клона, нет других источников — `FAIL` с кодом 1 и подсказкой
`--superpowers-from`. Оборванный клон не оставляется на диске.
- **`--check` остаётся офлайновым:** не клонирует и не обновляет, показывает
`ok: superpowers source (<path>, git <sha>)` для git-клона, `WARN` для ручной
копии без `.git`, legacy и «нет источника» — как в #49.
- **Тесты без сети:** локальный git-репозиторий через `DSH_SUPERPOWERS_REPO`
(`file://`), проверка первого клона, обновления до нового коммита, офлайна и
`--check`.
- **Документация.** README/PORT: quick start без ручного клона, офлайн-путь,
`DSH_SUPERPOWERS_REPO`, `--check` с sha.
## Non-goals
- Вендоринг superpowers в репозиторий — отклонён в #49 (D-1) и остаётся
отклонённым.
- Пиннинг тега/коммита и проверка подписи: «свежая версия» = последний коммит
default-ветки; воспроизводимость установки обеспечивается глубиной 1 и
записанным sha в выводе, а не неизменностью источника.
- Субмодули и LFS в клоне superpowers.
- Обновление ролей/локальных скиллов из сети: они по-прежнему из репозитория
обвязки.

View File

@ -0,0 +1,164 @@
# Spec Delta: dsh-superpowers-git
## Purpose
`dsh/install.sh --skills` получает superpowers из git сам: первый запуск —
клон, следующие — обновление до последнего коммита default-ветки. Явные
источники остаются приоритетнее и не ходят в сеть; `~/.claude` — по-прежнему
legacy-fallback с предупреждением.
## ADDED Requirements
### Requirement: Свежий superpowers из git
`dsh/install.sh --skills` MUST получать superpowers-скиллы из git, когда явный
источник не задан: клонировать при отсутствии клона и обновлять при каждом
следующем запуске.
- Репозиторий по умолчанию — `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`; рабочее дерево управляемое, локальные правки
там не сохраняются.
- После клона/обновления источником MUST считаться `$DSH_HOME/superpowers/skills`
(или сам каталог), только если в нём есть `using-superpowers/SKILL.md`.
- Вывод `--skills` MUST называть источник и git-версию:
`(source: <path>, git <short-sha>)`.
- `--check` MUST NOT выполнять сетевые операции: он показывает состояние
существующего клона (`ok: superpowers source (<path>, git <sha>)`) и не
создаёт `$DSH_HOME/superpowers`.
#### Scenario: Первый запуск клонирует источник
- **WHEN** `$DSH_HOME/superpowers` отсутствует, `DSH_SUPERPOWERS_REPO` указывает
на репозиторий superpowers
- **THEN** `--skills` клонирует его в `$DSH_HOME/superpowers`, копирует скиллы в
`$DSH_HOME/skills` и печатает `git <sha>`
#### Scenario: Повторный запуск обновляет клон
- **WHEN** клон уже есть, а в источнике появился новый коммит со скиллом
- **THEN** повторный `--skills` обновляет клон до нового коммита, и новый скилл
оказывается в `$DSH_HOME/skills`
#### Scenario: Нет сети, клон есть
- **WHEN** обновление клона не удалось (недоступный репозиторий), но
`$DSH_HOME/superpowers` содержит `using-superpowers/SKILL.md`
- **THEN** `--skills` печатает `WARN`, использует кэш, копирует скиллы и
завершается кодом 0
#### Scenario: Нет сети и нет клона
- **WHEN** клонирование не удалось, клона и других источников нет,
`$DSH_HOME/skills` пуст
- **THEN** `--skills` печатает `FAIL` с репозиторием и подсказкой
`--superpowers-from`, код 1; недоклонированный каталог не остаётся на диске
#### Scenario: Ручная копия без git
- **WHEN** `$DSH_HOME/superpowers` существует, не является git-клоном и содержит
`using-superpowers/SKILL.md`
- **THEN** каталог используется как есть, `--skills` печатает `WARN` о том, что
он не будет обновляться
#### Scenario: --check не ходит в сеть
- **WHEN** клон существует, а `DSH_SUPERPOWERS_REPO` указывает на недоступный
репозиторий
- **THEN** `--check` печатает `ok: superpowers source (<path>, git <sha>)`,
завершается без сетевых ошибок и ничего не клонирует
## MODIFIED Requirements
### Requirement: Источник superpowers задаётся при установке
`dsh/install.sh --skills` SHALL разрешать источник superpowers-скиллов в
порядке приоритета.
- Порядок: `--superpowers-from <dir>` → `$DSH_SUPERPOWERS_SOURCE` → git-клон
`$DSH_HOME/superpowers` (обновить существующий или склонировать из
`$DSH_SUPERPOWERS_REPO`/`https://github.com/obra/superpowers`) →
legacy-кэш Claude Code → legacy `~/.claude/skills`.
- Для явного источника (`--superpowers-from`, `$DSH_SUPERPOWERS_SOURCE`)
допускаются обе формы: корень superpowers (есть `skills/using-superpowers/SKILL.md`)
или сам каталог `skills`; сеть при явном источнике MUST NOT использоваться.
- Явно заданный, но невалидный источник MUST приводить к `FAIL` и коду
возврата 1: тихий откат на git/legacy запрещён.
- Копирование MUST быть заменой каталога назначения (`rm -rf` + `cp -R`), а не
симлинком: `~/.dsh/skills` не должен зависеть от каталога-источника.
#### Scenario: Явный источник без ~/.claude
- **WHEN** `HOME` — пустой temp-каталог без `~/.claude`, а `--superpowers-from`
указывает на каталог superpowers
- **THEN** `--skills` копирует скиллы в `$DSH_HOME/skills`, печатает источник и
завершается кодом 0
#### Scenario: Переменная окружения вместо флага
- **WHEN** задан `DSH_SUPERPOWERS_SOURCE=<dir>` и флаг `--superpowers-from` не передан
- **THEN** источник берётся из переменной; поведение совпадает с флагом
#### Scenario: Флаг важнее переменной
- **WHEN** заданы и `--superpowers-from`, и `DSH_SUPERPOWERS_SOURCE`
- **THEN** используется каталог из флага
#### Scenario: Невалидный явный источник
- **WHEN** `--superpowers-from` указывает на каталог без
`using-superpowers/SKILL.md`
- **THEN** `--skills` печатает `FAIL` с ожидаемой структурой и завершается
кодом 1, ничего не копируя из git/legacy-источников
#### Scenario: Явный источник не трогает git
- **WHEN** `--superpowers-from` задан, а `$DSH_HOME/superpowers` отсутствует
- **THEN** `--skills` не создаёт `$DSH_HOME/superpowers`
#### Scenario: Пиннинг в ~/.dsh
- **WHEN** существует ручная (не git) копия
`$DSH_HOME/superpowers/skills/using-superpowers/SKILL.md`, флаг и переменная не
заданы
- **THEN** источником служит эта копия, `--skills` печатает `WARN`, что она не
будет обновляться, и не превращает каталог в git-клон
### Requirement: Отсутствие источника не бывает тихим
Если источник superpowers не найден, `--skills` MUST различать первичную и
повторную установку и MUST NOT завершаться успехом молча.
- Если `$DSH_HOME/skills/using-superpowers/SKILL.md` уже существует —
`WARN` «источник не найден, копии сохранены» и код 0 (идемпотентный повтор).
- Если копий нет — `FAIL` с репозиторием (`$DSH_SUPERPOWERS_REPO`) и подсказкой
`--superpowers-from <dir>`; код возврата 1.
- Локальные скиллы обвязки (`dsh/skills/*`: `role`, `sdd`) MUST ставиться в
обоих случаях: они не зависят от superpowers.
#### Scenario: Первая установка без источника
- **WHEN** `HOME` пуст, `$DSH_HOME/skills` не существует, git-источник недоступен
и других источников нет
- **THEN** `--skills` печатает `FAIL`, код 1, но `$DSH_HOME/skills/role/SKILL.md`
и `$DSH_HOME/skills/sdd/SKILL.md` на месте
#### Scenario: Повторная установка без источника
- **WHEN** `$DSH_HOME/skills/using-superpowers/SKILL.md` уже есть, источника нет
- **THEN** `--skills` печатает `WARN`, код 0, установленные скиллы не удалены
### Requirement: ~/.claude — только legacy-fallback с предупреждением
superpowers-скиллы и роли MUST иметь рабочий путь `~/.dsh`; пути `~/.claude`
SHALL оставаться fallback'ом для машин, где `--skills --roles` ещё не
перезапускали, и MUST сопровождаться предупреждением.
- Установка: legacy-кэш Claude Code и `~/.claude/skills` допустимы как
источник, но `--skills` печатает `WARN: legacy superpowers source` с
подсказкой на git-источник или `--superpowers-from`.
- `--check`: legacy-источник — `WARN`, git-клон/явный источник — `ok`,
отсутствие — `WARN` с подсказкой, невалидный явный источник — `FAIL` и код 1.
- Runtime: bootstrap и резолвер ролей сохраняют порядок `~/.dsh` → `~/.claude`,
но при выборе legacy-пути один раз за процесс пишут в лог предупреждение.
- Удаление fallback'ов — отдельное изменение; в этом change они не удаляются.
#### Scenario: Legacy-кэш как источник установки
- **WHEN** в `HOME` есть только кэш Claude Code с superpowers, `$DSH_HOME` пуст,
git-источник недоступен
- **THEN** `--skills` копирует скиллы и печатает `WARN: legacy superpowers source`
#### Scenario: Runtime-предупреждение
- **WHEN** bootstrap резолвит `using-superpowers` из `~/.claude/...`
- **THEN** в лог один раз за процесс попадает предупреждение с предложением
выполнить `dsh/install.sh --skills`
#### Scenario: ~/.dsh приоритетнее
- **WHEN** существуют и `~/.dsh/skills/using-superpowers/SKILL.md`, и legacy-копия
- **THEN** используется `~/.dsh`, предупреждения нет