From 15cc2e38d790d305fa5f04367f9d66fbba4f2865 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:09:11 +0400 Subject: [PATCH 1/7] =?UTF-8?q?docs(openspec):=20change=20dsh-install-sour?= =?UTF-8?q?ce=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-24-dsh-install-source.md | 293 ++++++++++++++++++ .../changes/dsh-install-source/.openspec.yaml | 2 + openspec/changes/dsh-install-source/design.md | 148 +++++++++ .../changes/dsh-install-source/proposal.md | 68 ++++ .../specs/dsh-install/spec.md | 147 +++++++++ 5 files changed, 658 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-dsh-install-source.md create mode 100644 openspec/changes/dsh-install-source/.openspec.yaml create mode 100644 openspec/changes/dsh-install-source/design.md create mode 100644 openspec/changes/dsh-install-source/proposal.md create mode 100644 openspec/changes/dsh-install-source/specs/dsh-install/spec.md diff --git a/docs/superpowers/plans/2026-10-24-dsh-install-source.md b/docs/superpowers/plans/2026-10-24-dsh-install-source.md new file mode 100644 index 0000000..b6af05b --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-dsh-install-source.md @@ -0,0 +1,293 @@ +# DSH install without ~/.claude — 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 --global --skills --roles` ставит рабочий DSH на машине без `~/.claude`, а `~/.claude` остаётся только явно предупреждающим legacy-fallback'ом. + +**Architecture:** Источник superpowers разрешается в `install.sh` по приоритету флаг → env → пиннинг `~/.dsh/superpowers` → legacy-кэш CC; отсутствие источника — `FAIL` (если копий нет) или `WARN` (идемпотентный повтор). `install_global` переписывает пути ролей в установленной копии `AGENTS.md`. Bundle сохраняет fallback-порядок, но один раз за процесс сообщает, что взял legacy-путь. + +**Tech Stack:** bash (`set -euo pipefail`), `python3` stdlib, Node.js ESM (node:test), Cordis-бандл DSH. + +**Spec:** `openspec/changes/dsh-install-source/specs/dsh-install/spec.md` (дизайн — `openspec/changes/dsh-install-source/design.md`). + +## Global Constraints + +- `global/CLAUDE.md` MUST NOT меняться: его читает Claude Code. +- `install.sh` MUST NOT выполнять `git clone`, `curl` и любые сетевые операции. +- Источник superpowers различается по файлу `using-superpowers/SKILL.md`; принимаются и корень с `skills/`, и сам `skills/`. +- Порядок источников: `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → `$DSH_HOME/superpowers/skills` → кэш CC (`~/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills`, новая версия) → `~/.claude/skills`. +- Legacy-пути остаются рабочими: удаление fallback'ов — не эта задача. +- Тесты DSH идут без DSH runtime: `bash dsh/tests/run.sh`; все тесты на temp `HOME`. +- Маркировка находок: `T.`; решения в этом плане уже с маркерами `D-1…D-6` в design.md. + +## Review Focus + +- `--skills` без источника при уже установленных `~/.dsh/skills`: копии не должны исчезнуть, код 0, `WARN`. +- Явный источник без `using-superpowers/SKILL.md`: `FAIL`, ни одного скопированного файла из legacy — иначе опечатка маскируется. +- `DSH_SUPERPOWERS_SOURCE` пустой или невалидный: пустой игнорируется, невалидный — `FAIL` (не откат на legacy). +- Путь источника с пробелами: `--superpowers-from "$dir with space"` должен работать (кавычки на всех уровнях). +- Runtime: выбран legacy-путь → одно предупреждение за процесс; выбран `~/.dsh` → предупреждения нет. +- `--global` повторно: бэкап создаётся, в `AGENTS.md` нет `~/.claude/roles`, источник в репозитории не тронут. + +--- + +### Task 1: install.sh — источник superpowers, FAIL/WARN, `--check` + +**Files:** +- Modify: `dsh/install.sh` (`superpowers_skills` → новый резолвер; `check`; `install_skills`; разбор аргументов; `usage`) +- Modify: `dsh/tests/install.test.mjs` +- Modify: `dsh/README.md` (quick start: получение источника, флаг, пиннинг) + +**Interfaces:** +- Produces: + - флаг `--superpowers-from ` (и `--superpowers-from=`), env `DSH_SUPERPOWERS_SOURCE`; + - bash-функции `normalize_superpowers_dir ` (печатает `skills`-каталог или rc 1) и `superpowers_source` (печатает `\t`, rc 1 если источника нет); + - вывод `--skills`: `copied N superpowers skills into (source: )`; при legacy — строка `WARN: legacy superpowers source ; prefer --superpowers-from or ~/.dsh/superpowers`; + - вывод `--check`: `ok: superpowers source ()` / `WARN: legacy superpowers source (); run --skills from a DSH-owned source` / `WARN: no superpowers source; run: git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`. +- Consumes: `$DSH_HOME` (по умолчанию `$HOME/.dsh`). + +- [ ] **Step 1: Написать падающие тесты в `dsh/tests/install.test.mjs`** + +Тесты (каждый — отдельный `test(...)`, temp `HOME`, `spawnSync('bash', [script, ...])`): + +```js +test('--skills takes superpowers from --superpowers-from in a clean HOME', ...) +// HOME без ~/.claude; источник: /src/superpowers/skills/using-superpowers/SKILL.md +// + brainstorming/SKILL.md; запуск: --skills --superpowers-from /src/superpowers +// assert: status 0; stdout /source: <...>/src\/superpowers\/skills/; +// /skills/using-superpowers/SKILL.md и role/sdd на месте; +// existsSync(/.claude) === false + +test('--superpowers-from accepts the skills directory itself', ...) +// тот же fixture, флаг указывает на /src/superpowers/skills; status 0 + +test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', ...) +// два разных fixture-источника (A: body "a", B: body "b"); env=B, флаг=A; +// в /skills/using-superpowers/SKILL.md лежит "a" + +test('--skills fails when no source and nothing installed', ...) +// пустой HOME; --skills; assert status 1; stderr /FAIL: no superpowers source/; +// stdout /git clone --depth 1 https:\/\/github.com\/obra\/superpowers/; +// role и sdd всё равно на месте + +test('--skills keeps installed copies when the source is gone', ...) +// HOME с /skills/using-superpowers/SKILL.md (заранее), без источника; +// --skills; status 0; stdout /WARN: no superpowers source/; файл не удалён + +test('explicit invalid source fails without touching legacy', ...) +// HOME с legacy-кэшем (как в первом тесте файла) и с --superpowers-from <пустой dir>; +// status 1; stderr /FAIL: .*has no using-superpowers\/SKILL\.md/; +// /skills/brainstorming не создан + +test('legacy plugin cache still works and warns', ...) +// HOME с ~/.claude/plugins/cache/.../skills; --skills; status 0; +// stdout /WARN: legacy superpowers source/ и /copied 2 superpowers skills/ + +test('--check reports the pinned source', ...) +// HOME с /superpowers/skills/using-superpowers/SKILL.md; +// --check; stdout /ok: superpowers source \(.*superpowers\/skills\)/ +``` + +- [ ] **Step 2: Прогнать и увидеть падение** + +Run: `node --test dsh/tests/install.test.mjs` +Expected: новые тесты падают (`status 0 !== 1`, нет `source:` в выводе); остальные зелёные. + +- [ ] **Step 3: Реализовать в `dsh/install.sh`** + +- Разбор аргументов: `--superpowers-from` (следующий аргумент) и `--superpowers-from=*` (через `"${arg#*=}"`); ошибка `unknown option` для голого `--superpowers-from`. +- `normalize_superpowers_dir dir` → `"$dir/skills"` если есть `"$dir/skills/using-superpowers/SKILL.md"`, иначе `"$dir"` если есть `"$dir/using-superpowers/SKILL.md"`, иначе rc 1. +- `superpowers_source` — порядок из Global Constraints; для legacy-кэша сохранить текущий `ls -d … | sort -V | tail -1`; печатать `\t`; явный невалидный источник — `FAIL` в stderr и rc 2, чтобы `install_skills` не печатал «нет источника». +- `install_skills`: при rc 2 — `return 1`; при rc 1 и наличии `"$DSH_HOME/skills/using-superpowers/SKILL.md"` — `WARN` + `return 0`; иначе `FAIL` с командой клона + `return 1`. Копирование — как сейчас (`rm -rf` + `cp -R`), плюс `(source: )` в итоговой строке. +- `check`: `ok: dsh skills` оставить; добавить ветку источника (`ok`/`WARN legacy`/`WARN none` по kind); legacy-источник не делает `check` падающим. +- Dispatch: `install_skills || status=1; install_local_skills` — локальные скиллы ставятся всегда. +- `usage`: строка `--superpowers-from ` с описанием трёх источников. + +- [ ] **Step 4: Прогнать тесты** + +Run: `node --test dsh/tests/install.test.mjs` +Expected: PASS всех тестов файла. + +- [ ] **Step 5: Обновить quick start в `dsh/README.md`** + +Один блок: клон `git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers` (или `--superpowers-from `), затем `--all`; в Known limits — «источник superpowers задаётся установщиком; `~/.claude` — legacy». + +- [ ] **Step 6: Commit** + +```bash +git add dsh/install.sh dsh/tests/install.test.mjs dsh/README.md +git commit -m "feat(dsh): install superpowers from an explicit source (#49)" +``` + +--- + +### Task 2: `--global` — пути ролей и формат инструкций + +**Files:** +- Modify: `dsh/install.sh` (`install_global`) +- Modify: `dsh/tests/install.test.mjs` + +**Interfaces:** +- Produces: `AGENTS.md`, где `~/.claude/roles` заменён на `~/.dsh/roles`, шапка содержит строки: + - `DSH adapter: role files are ~/.dsh/roles (installed by --roles); legacy ~/.claude/roles is a migration fallback.` + - `DSH adapter: superpowers skills are ~/.dsh/skills (installed by --skills from --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers); the ~/.claude cache is a legacy fallback.` + - `DSH adapter: a project CLAUDE.md/AGENTS.md is the instruction-file format of this harness; it does not imply a Claude Code runtime.` +- Consumes: `$ROOT/global/CLAUDE.md` (только чтение). + +- [ ] **Step 1: Падающий тест** + +```js +test('--global installs DSH role paths and explains the instruction format', ...) +// temp HOME; source global/CLAUDE.md до запуска (readFileSync); +// spawn --global; assert AGENTS.md: /~\/\.dsh\/roles\/dev\.md/, !/~\/\.claude\/roles/, +// /instruction-file format of this harness/; +// assert global/CLAUDE.md побайтно равен снимку; assert создан бэкап при повторном --global +``` + +- [ ] **Step 2: Прогнать, увидеть падение** + +Run: `node --test dsh/tests/install.test.mjs` +Expected: FAIL — в `AGENTS.md` пока `~/.claude/roles`. + +- [ ] **Step 3: Реализовать** + +В `install_global` тело `global/CLAUDE.md` подать через `python3 - "$ROOT/global/CLAUDE.md"` с `text.replace("~/.claude/roles", "~/.dsh/roles")` и `sys.stdout.write`; три adapter-строки выше заменить текущие про роли и bootstrap; добавить строку про формат инструкций. + +- [ ] **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): DSH-owned role paths in installed instructions (#49)" +``` + +--- + +### Task 3: Runtime — legacy-предупреждение один раз + +**Files:** +- Create: `dsh/bundle/lib/legacy.js` +- Modify: `dsh/bundle/lib/bootstrap.js` (`prepareBootstrap`) +- Modify: `dsh/bundle/index.js` (`apply` после `resolveRolesDir`) +- Modify: `dsh/tests/bootstrap.test.mjs`, `dsh/tests/role.test.mjs` +- Modify: `dsh/README.md` (Known limits), `dsh/PORT.md` (§3, §5, §7, §8) + +**Interfaces:** +- Produces: + - `isLegacyClaudePath(path, home = homedir()) -> boolean` — истина для `/.claude/...`; + - `warnLegacyOnce(logger, kind, path) -> void` — пишет `[dsh-harness] %s uses the legacy Claude Code source %s; run dsh/install.sh --%s` не более одного раза на `kind`+`path` за процесс; + - `prepareBootstrap` вызывает `warnLegacyOnce(logger, 'bootstrap', resolved)`, когда путь legacy; + - `apply` вызывает `warnLegacyOnce(ctx.logger, 'roles', rolesDir)` после `resolveRolesDir`. +- Consumes: `resolveRolesDir`, `resolveBootstrapSkill` — без изменения сигнатур. + +- [ ] **Step 1: Падающие тесты** + +```js +// dsh/tests/bootstrap.test.mjs +test('prepareBootstrap warns once when the legacy ~/.claude skill is used', ...) +// temp home: только ~/.claude/skills/using-superpowers/SKILL.md; +// logger = { warnings: [], warn(msg, ...args){ this.warnings.push([msg, ...args]) } }; +// prepareBootstrap({ home, logger, skillPath: '' }) дважды; +// assert warnings.length === 1; assert /legacy Claude Code source/ + +test('prepareBootstrap is silent for the DSH-owned copy', ...) +// temp home: только ~/.dsh/skills/using-superpowers/SKILL.md; warnings.length === 0 + +// dsh/tests/role.test.mjs +test('isLegacyClaudePath detects ~/.claude paths only', ...) +// true для join(home,'.claude','roles','dev.md'); false для join(home,'.dsh','roles','dev.md') +// и для join(home,'.claude-other','x') +``` + +- [ ] **Step 2: Прогнать, увидеть падение** + +Run: `node --test dsh/tests/bootstrap.test.mjs dsh/tests/role.test.mjs` +Expected: FAIL — модуля `legacy.js` нет, предупреждений ноль. + +- [ ] **Step 3: Реализовать** + +`lib/legacy.js`: `import { homedir } from 'node:os'; import { sep } from 'node:path';` +`isLegacyClaudePath` сравнивает через `startsWith(join(home, '.claude') + sep)`; `warnLegacyOnce` держит `const warned = new Set()` и ключ `` `${kind}|${path}` ``. + +`prepareBootstrap`: после `resolveBootstrapSkill`, если `isLegacyClaudePath(resolved, home)` — вызвать предупреждение с `kind: 'bootstrap'` (суффикс `--skills`). + +`index.js`: импорт `warnLegacyOnce`; в `apply` после `const rolesDir = ...` — `if (isLegacyClaudePath(rolesDir)) warnLegacyOnce(ctx.logger, 'roles', rolesDir)`. + +- [ ] **Step 4: Прогнать тесты** + +Run: `bash dsh/tests/run.sh` +Expected: PASS всего набора. + +- [ ] **Step 5: Документация** + +`dsh/README.md`: Known limits — источник superpowers (`--superpowers-from`/env/`~/.dsh/superpowers`), `~/.claude` — legacy с предупреждением; config-комментарий `rolesDir`/`bootstrapSkillPath`. `dsh/PORT.md`: §3 «install-time зависимость» убрать из deferred; §5 — источник и `--superpowers-from`; §7 — риск `~/.claude` переписать как legacy-fallback; §8 — фаза #49 закрыта. + +- [ ] **Step 6: Commit** + +```bash +git add dsh/bundle/lib/legacy.js dsh/bundle/lib/bootstrap.js dsh/bundle/index.js \ + dsh/tests/bootstrap.test.mjs dsh/tests/role.test.mjs dsh/README.md dsh/PORT.md +git commit -m "feat(dsh): warn about legacy ~/.claude sources at runtime (#49)" +``` + +--- + +### Task 4: Приёмка, спека, PR + +**Files:** +- Modify (по результатам): `openspec/changes/dsh-install-source/design.md` (раздел приёмки), `dsh/PORT.md` (verified live) + +**Interfaces:** +- Consumes: всё предыдущее. + +- [ ] **Step 1: Полный прогон** + +Run: +```bash +bash dsh/tests/run.sh +bash tests/run.sh +openspec validate dsh-install-source --strict +``` +Expected: все три — код 0, `dsh/tests` зелёный, `Change 'dsh-install-source' is valid`. + +- [ ] **Step 2: Smoke в чистом HOME (без `~/.claude`)** + +```bash +TMP=$(mktemp -d); HOME="$TMP" DSH_HOME="$TMP/.dsh" bash dsh/install.sh \ + --global --skills --roles --superpowers-from <путь-к-superpowers> +# ожидание: код 0; файлы AGENTS.md, skills/{using-superpowers,role,sdd}/SKILL.md, roles/dev.md +# отсутствие $TMP/.claude; --check печатает ok: superpowers source и ok: roles +``` + +Bootstrap в этом HOME проверить Node-ом: +`node -e "import('./dsh/bundle/lib/bootstrap.js').then(m=>console.log(m.prepareBootstrap({home: process.env.HOME}).marker))"` — маркер на месте, предупреждений нет. + +- [ ] **Step 3: Зафиксировать приёмку** + +В `design.md` добавить `### Приёмка: прогон <дата>` с командами и результатами; в `dsh/PORT.md` — что проверено, что осталось владельцу (fresh-session smoke без `~/.claude`). + +- [ ] **Step 4: Commit, push, PR** + +```bash +git add -A +git commit -m "docs(dsh): acceptance for install without ~/.claude (#49)" +git push -u origin feat/49-dsh-install-source +tea pulls create --head feat/49-dsh-install-source --base main \ + --title "DSH: установка без зависимости от ~/.claude (#49)" \ + --description "<описание + Rulings + маркеры + what was tested>" +``` + +- [ ] **Step 5: Итоговый отчёт** + +Таблица находок с маркерами и статусами, что проверено, что осталось человеку (merge, fresh-session smoke, `--skills --roles` на реальной машине). + +## Acceptance + +- Спека `dsh-install` покрыта тестами: источник (5 сценариев), отсутствие источника (2), legacy (2), инструкции (2), чистый HOME (2). +- `bash dsh/tests/run.sh`, `bash tests/run.sh`, `openspec validate --strict` — зелёные. +- Temp `HOME` без `~/.claude` проходит `--global --skills --roles`, bootstrap даёт маркер, `--check` зелёный. +- `global/CLAUDE.md` не изменён; CC-контур (`bash tests/run.sh`) не затронут. diff --git a/openspec/changes/dsh-install-source/.openspec.yaml b/openspec/changes/dsh-install-source/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/dsh-install-source/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/dsh-install-source/design.md b/openspec/changes/dsh-install-source/design.md new file mode 100644 index 0000000..7c579a0 --- /dev/null +++ b/openspec/changes/dsh-install-source/design.md @@ -0,0 +1,148 @@ +# Design: установка DSH без зависимости от ~/.claude + +Issue: #49. Спека: `openspec/changes/dsh-install-source/specs/dsh-install/spec.md`. +Исходные факты: `dsh/PORT.md` §3/§5/§7, issue #37/#38 (runtime-отвязка уже +сделана), ответ владельца 24.10.2026 («нормально так» — без вендоринга). + +## 1. Что уже сделано и чего не хватает + +| Слой | Источник в runtime | Зависимость от `~/.claude` | +|---|---|---| +| bootstrap `using-superpowers` | `~/.dsh/skills` → `~/.agents/skills` → кэш CC → `~/.claude/skills` | только fallback | +| роли `/role`, skill `role` | `~/.dsh/roles` → `~/.claude/roles` → repo `roles/` | только fallback | +| установка `--skills` | кэш плагина CC | **жёсткая** | +| установка `--roles` | repo `roles/` | нет | +| установленные инструкции | `global/CLAUDE.md` + adapter-шапка | **текст путей ролей** | + +То есть #37 снял зависимость в том, что исполняется, и оставил её в том, что +устанавливается. Первая же команда `--skills` на чистой машине не находит +источник, печатает `WARN` и оставляет bootstrap без скилла — при этом установка +формально успешна. + +## 2. Решения + +### D-1 (решение владельца). Без вендоринга superpowers + +Копию апстрима в репозиторий не кладём. Обоснование: superpowers живёт своей +версией (в кэше уже 6.4.1 и 7.0.0), репозиторий обвязки не должен превращаться +в зеркало; обновление вендорной копии — ручная синхронизация, которая стареет +молча. Цена: install-time источник нужно получить один раз (клон), это лишний +шаг в quick start. + +### D-2. Три уровня источника, один формат + +Порядок: `--superpowers-from` → `DSH_SUPERPOWERS_SOURCE` → +`$DSH_HOME/superpowers/skills` → legacy-кэш CC → legacy `~/.claude/skills`. + +- Флаг и переменная принимают **оба** каталога: корень superpowers (с `skills/`) + и сам `skills/`. Клон `git clone … ~/.dsh/superpowers` даёт первый вид, кэш + CC — второй; пользователь не должен помнить, какой именно. +- `$DSH_HOME/superpowers` — рекомендуемое место пиннинга: один клон на машину, + дальше `--skills` воспроизводим без флага и без `~/.claude`. +- Явно заданный невалидный источник — `FAIL`, а не fallback: иначе опечатка в + пути тихо даёт установку из legacy-кэша, и на чистой машине это снова + «установка прошла, bootstrap пуст». + +### D-3. `FAIL` только когда ставить нечего + +`--skills` без источника: +- `$DSH_HOME/skills/using-superpowers/SKILL.md` есть → `WARN` + код 0 + (идемпотентный повтор, старые копии не трогаем); +- нет → `FAIL` + код 1 + готовая команда клона. + +Локальные скиллы (`role`, `sdd`) ставятся в обоих случаях: они из репозитория и +от superpowers не зависят. Из-за этого `--skills` может вернуть 1, уже записав +часть файлов — это сказано в `FAIL`-сообщении. + +### D-4. Legacy-пути остаются, но говорят о себе + +`~/.claude` поддерживается как fallback: машины, где `--skills --roles` ещё не +перезапускали, не должны сломаться на этом change. Предупреждения: + +- `--skills` из legacy-источника → `WARN: legacy superpowers source`; +- `--check` → `WARN` для legacy и для «источника нет»; +- bootstrap/`resolveRolesDir` при выборе `~/.claude` → одно предупреждение за + процесс (module-level `Set`), не на каждый `agent/pre-step`. + +Удаление fallback'ов — отдельная задача: сначала все машины должны переехать на +`~/.dsh` (и это видно по `WARN`), потом код можно чистить. Цена: путь +`~/.claude` остаётся в bundle, и code review видит его ещё один цикл. + +### D-5. Инструкции переписываются при установке, не в источнике + +`global/CLAUDE.md` — общий с Claude Code, его правит только CC-контур. Поэтому +`install_global` пишет `AGENTS.md` в два шага: adapter-шапка (как сейчас, плюс +явная строка про формат инструкций) и тело `global/CLAUDE.md`, прогнанное через +замену `~/.claude/roles` → `~/.dsh/roles`. + +Замена точечная и проверяемая тестом: после `--global` в `AGENTS.md` есть +`~/.dsh/roles/dev.md` и нет `~/.claude/roles`; сам `global/CLAUDE.md` не меняется +(тест сравнивает содержимое до/после). Цена: расхождение между источником и +установленной копией теперь intentional, и его надо помнить при правках CC-текста. + +### D-6. `--check` не падает без источника + +`--check` — диагностика, а не установка: отсутствие superpowers-источника +остаётся `WARN`, код возврата меняют только жёсткие `FAIL` (python3, git, node, +хуки, бандл). Так же уже сделано для openspec (#42, R-4). + +## 3. Формулировки, которые попадут в установленный AGENTS.md + +- `DSH adapter: role files are ~/.dsh/roles (installed by --roles); legacy ~/.claude/roles is a migration fallback.` +- `DSH adapter: superpowers skills are ~/.dsh/skills (installed by --skills from --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers); the ~/.claude cache is a legacy fallback.` +- `DSH adapter: a project CLAUDE.md/AGENTS.md is the instruction-file format of this harness; it does not imply a Claude Code runtime.` +- Тело `global/CLAUDE.md` — с заменой `~/.claude/roles` → `~/.dsh/roles`; строка + про `role-hint` остаётся, adapter-шапка говорит, что хука в DSH нет и роль + выводится из первого сообщения. + +## 4. Решения, принятые без обсуждения + +- **2026-10-24. Клон для пиннинга — `git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`.** + Это апстрим superpowers (public repo). `install.sh` его не запускает: сеть в + установке запрещена (D-1/спека). Цена ошибки: неверный URL — баг + документации, ловится первой же установкой на чистой машине; лечится одной + строкой в README/PORT. +- **2026-10-24. `--superpowers-from` принимает и корень, и `skills/`.** + Обоснование: два реальных источника дают разные формы (клон и кэш CC). + Цена: две ветки разбора и два теста; ошибка разбора видна сразу по `FAIL`. +- **2026-10-24. Предупреждение о legacy в runtime — один раз за процесс.** + `agent/pre-step` вызывается на каждом шаге; предупреждение на каждый шаг + утопило бы лог. Цена: после перезапуска процесса предупреждение повторится — + это ожидаемо; диагностика — `--check`. +- **2026-10-24. `--check` проверяет у пиннинга только наличие `using-superpowers`.** + Полная инвентаризация набора — работа `--skills` (он копирует всё, что есть). + Цена: неполный клон пройдёт `--check` и даст частичный набор скиллов; признак + — отсутствие ожидаемого имени в каталоге скиллов сессии. +- **2026-10-24. `tasks.md` OpenSpec не заполняется.** + Задачи живут в плане superpowers, как требует `CLAUDE.md` репозитория. Цена: + `openspec status` показывает незаполненный `tasks.md`; `archive` это не + блокирует (уже так в `dsh-openspec`). + +## 5. Риски + +- **`set -euo pipefail` против «частичного успеха».** `install_skills` при + `FAIL` возвращает 1; в `--skills` он вызывается через `|| status=1`, иначе + `install_local_skills` не выполнится. Тест на «первую установку без + источника» проверяет оба файла и код возврата. +- **Legacy-fallback маскирует неправильную настройку.** Лечится `WARN` в + установке, `--check` и runtime-предупреждением; полное удаление — D-4. +- **Замена `~/.claude/roles` → `~/.dsh/roles` в тексте.** Заменяем только этот + префикс, а не `~/.claude` целиком: в тексте есть упоминания хуков/CC, которые + DSH-версии не касаются. Тест проверяет и наличие нового пути, и отсутствие + старого. +- **Проверка «чистый HOME» — юнит-уровень.** Реальный fresh-session smoke в DSH + делает владелец: сессия должна подняться без `~/.claude` и показать + superpowers-скиллы. В PR это отмечено как единственный ручной шаг приёмки. +- **Апстрим superpowers может переехать.** Явный источник проверяется по + `using-superpowers/SKILL.md`; если апстрим переименует каталог, `FAIL` скажет, + что искать. + +## 6. Приёмка + +- `openspec validate dsh-install-source --strict` — change валиден. +- `bash dsh/tests/run.sh` — зелёный, включая новые тесты источника, `AGENTS.md` + и legacy-предупреждений. +- `bash tests/run.sh` — регрессий в CC-контуре нет. +- Temp `HOME` без `~/.claude`: `--global --skills --roles` с + `--superpowers-from`; `--check`; `prepareBootstrap({home})` — маркер на месте. +- Живой fresh-session smoke без `~/.claude` — за владельцем (в PR отмечено). diff --git a/openspec/changes/dsh-install-source/proposal.md b/openspec/changes/dsh-install-source/proposal.md new file mode 100644 index 0000000..8229787 --- /dev/null +++ b/openspec/changes/dsh-install-source/proposal.md @@ -0,0 +1,68 @@ +# Proposal: установка DSH без зависимости от ~/.claude + +Issue: #49 (роадмап порта — #48). Источники: +- `dsh/PORT.md`, разделы 3 (deferred), 5 (install), 7 (risks: «`~/.claude` + dependence»); +- issue #37/#38: runtime-отвязка skills и roles от `~/.claude` — сделана; +- ответ владельца 24.10.2026 на вопрос о источнике superpowers: «нормально так» + (явный путь/`DSH_SUPERPOWERS_SOURCE`, без вендоринга в репозиторий). + +## Why + +После #37/#38 DSH не зависит от `~/.claude` в runtime: bootstrap читает +`~/.dsh/skills`, роли — `~/.dsh/roles`, `--skills --roles` копируют файлы. +Осталась зависимость **на этапе установки**: `dsh/install.sh --skills` берёт +superpowers только из кэша плагина Claude Code +(`~/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills`). + +Практические следствия: + +- на машине без Claude Code свежая установка не может поставить superpowers: + `--skills` печатает `WARN` и молча ничего не копирует, bootstrap остаётся + пустым (`superpowers bootstrap skill not found; skipping injection`); +- после отката/удаления Claude Code установка воспроизводится только из уже + лежащих копий в `~/.dsh/skills` — то есть «повторяемая установка» существует + лишь пока никто не удалил `~/.dsh`; +- acceptance порта (#48, DoD) требует пройти полный цикл в чистом проекте, где + CC-харнес не участвует, — сейчас первый шаг цикла упирается в `~/.claude`; +- в установленных инструкциях `~/.dsh/AGENTS.md` остались пути + `~/.claude/roles/.md` (унаследованы из `global/CLAUDE.md`, общего с CC): + adapter и skill `role` их перекрывают, но текст путает агента и человека. + +## What Changes + +- **Источник superpowers задаётся при установке.** `dsh/install.sh --skills` + разрешает источник в порядке: `--superpowers-from ` → `DSH_SUPERPOWERS_SOURCE` + → пиннинг `$DSH_HOME/superpowers/skills` → (legacy) кэш Claude Code → + (legacy) `~/.claude/skills`. Явно заданный, но невалидный источник — `FAIL`, + а не тихий откат на legacy. +- **Нет источника — нет тихого WARN.** Если `~/.dsh/skills` уже содержит + `using-superpowers`, повторный `--skills` предупреждает и сохраняет копии + (идемпотентность). Если копий нет — `FAIL` с готовой командой + `git clone … ~/.dsh/superpowers`, код возврата 1. Локальные скиллы обвязки + (`role`, `sdd`) ставятся в обоих случаях. +- **`--check` показывает источник.** `ok: superpowers source ()` для + явного/пиннинга, `WARN: legacy superpowers source` для `~/.claude`, + `WARN` с подсказкой, когда источника нет. +- **Legacy-пути остаются fallback'ом с предупреждением.** Runtime-порядок + `~/.dsh` → `~/.claude` не меняется; bundle один раз за процесс пишет в лог, + что bootstrap/роли взяты из legacy-источника. Удаление fallback'ов — не эта + задача (см. design, D-2). +- **Установленные инструкции DSH не ссылаются на `~/.claude`.** `install_global` + заменяет в копии `~/.claude/roles` → `~/.dsh/roles` и явно говорит, что + проектный `CLAUDE.md`/`AGENTS.md` — формат инструкций DSH, а не runtime + Claude Code. `global/CLAUDE.md` в репозитории не меняется: его читает и CC. +- **Тесты без DSH runtime.** Чистый temp `HOME` без `~/.claude`: установка с + `--superpowers-from`, bootstrap с этим HOME, `FAIL`/`WARN` при отсутствии + источника, legacy-предупреждения, содержимое `AGENTS.md`. +- **Документация.** `dsh/README.md` (quick start с получением источника, + known limits), `dsh/PORT.md` (§3, §5, §7, §8). + +## Non-goals + +- Вендоринг superpowers в репозиторий обвязки: апстрим живёт своей версией, + копия в репозитории стареет молча (design, D-1). +- `git clone` внутри `install.sh`: установка не должна требовать сети. +- Удаление legacy-fallback'ов из `bootstrap.js`/`index.js`: обратная + совместимость машин, где `--skills --roles` ещё не перезапускали (D-2). +- Проектный слой ролей, briefing, harness-check, harness-doctor — свои фазы #48. diff --git a/openspec/changes/dsh-install-source/specs/dsh-install/spec.md b/openspec/changes/dsh-install-source/specs/dsh-install/spec.md new file mode 100644 index 0000000..3ebc627 --- /dev/null +++ b/openspec/changes/dsh-install-source/specs/dsh-install/spec.md @@ -0,0 +1,147 @@ +# Spec Delta: dsh-install + +## Purpose + +Слой установки DSH-обвязки: откуда берутся superpowers-скиллы и роли, что +делает `dsh/install.sh --skills/--roles`, какие пути попадают в установленные +инструкции. В Claude Code источником служит кэш плагина в `~/.claude`; в DSH +источник задаётся установщиком явно (аргумент, переменная или пиннинг в +`~/.dsh`), а `~/.claude` остаётся только legacy-fallback'ом с предупреждением. +Контракт нужен, чтобы свежая установка на машине без Claude Code проходила +полный цикл #48 и чтобы путь `~/.claude` не возвращался в runtime незаметно. + +## ADDED Requirements + +### Requirement: Источник superpowers задаётся при установке +`dsh/install.sh --skills` SHALL разрешать источник superpowers-скиллов в +порядке приоритета и MUST NOT исполнять `git clone` и любые сетевые операции. + +- Порядок: `--superpowers-from ` → `$DSH_SUPERPOWERS_SOURCE` → + `$DSH_HOME/superpowers/skills` (пиннинг; допускается и корень + `$DSH_HOME/superpowers` с подкаталогом `skills/`) → legacy-кэш Claude Code → + legacy `~/.claude/skills`. +- Для явного источника (`--superpowers-from`, `$DSH_SUPERPOWERS_SOURCE`) + допускаются обе формы: корень superpowers (есть `skills/using-superpowers/SKILL.md`) + или сам каталог `skills`. +- Явно заданный, но невалидный источник MUST приводить к `FAIL` и коду + возврата 1: тихий откат на 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=` и флаг `--superpowers-from` не передан +- **THEN** источник берётся из переменной; поведение совпадает с флагом + +#### Scenario: Флаг важнее переменной +- **WHEN** заданы и `--superpowers-from`, и `DSH_SUPERPOWERS_SOURCE` +- **THEN** используется каталог из флага + +#### Scenario: Невалидный явный источник +- **WHEN** `--superpowers-from` указывает на каталог без + `using-superpowers/SKILL.md` +- **THEN** `--skills` печатает `FAIL` с ожидаемой структурой и завершается + кодом 1, ничего не копируя из legacy-источников + +#### Scenario: Пиннинг в ~/.dsh +- **WHEN** существует `$DSH_HOME/superpowers/skills/using-superpowers/SKILL.md` + и флаг/переменная не заданы +- **THEN** источником служит этот каталог, `--check` печатает + `ok: superpowers source ()` + +### Requirement: Отсутствие источника не бывает тихим +Если источник superpowers не найден, `--skills` MUST различать первичную и +повторную установку и MUST NOT завершаться успехом молча. + +- Если `$DSH_HOME/skills/using-superpowers/SKILL.md` уже существует — + `WARN` «источник не найден, копии сохранены» и код 0 (идемпотентный повтор). +- Если копий нет — `FAIL` с готовой командой получения источника + (`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`) + и код возврата 1. +- Локальные скиллы обвязки (`dsh/skills/*`: `role`, `sdd`) MUST ставиться в + обоих случаях: они не зависят от superpowers. + +#### Scenario: Первая установка без источника +- **WHEN** `HOME` пуст, `$DSH_HOME/skills` не существует, источника нет +- **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` с + подсказкой перейти на пиннинг/флаг. +- `--check`: legacy-источник — `WARN`, явный/пиннинг — `ok`, отсутствие — + `WARN` с подсказкой. +- Runtime: bootstrap и резолвер ролей сохраняют порядок `~/.dsh` → `~/.claude`, + но при выборе legacy-пути один раз за процесс пишут в лог предупреждение. +- Удаление fallback'ов — отдельное изменение; в этом change они не удаляются. + +#### Scenario: Legacy-кэш как источник установки +- **WHEN** в `HOME` есть только кэш Claude Code с superpowers, `$DSH_HOME` пуст +- **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`, предупреждения нет + +### Requirement: Установленные инструкции DSH не ссылаются на ~/.claude +`dsh/install.sh --global` MUST писать `$DSH_HOME/AGENTS.md` так, чтобы пути +ролей указывали на DSH-слой, а формат инструкций не выдавался за runtime +Claude Code. + +- Строки `~/.claude/roles` в установленной копии MUST заменяться на + `~/.dsh/roles`; `global/CLAUDE.md` в репозитории MUST NOT меняться (его + читает Claude Code). +- Шапка установленного файла MUST говорить, что проектный `CLAUDE.md` / + `AGENTS.md` — это формат файлов инструкций DSH, а не запущенный Claude Code, + и что роли лежат в `~/.dsh/roles` (`--roles`), а superpowers-скиллы — в + `~/.dsh/skills` (`--skills`). +- Повторный `--global` обязан делать бэкап прежнего `AGENTS.md` (как сейчас). + +#### Scenario: Пути ролей после установки +- **WHEN** выполнен `--global` во временный `DSH_HOME` +- **THEN** в `AGENTS.md` есть `~/.dsh/roles/dev.md` и нет `~/.claude/roles` + +#### Scenario: global/CLAUDE.md не тронут +- **WHEN** выполнен `--global` +- **THEN** `global/CLAUDE.md` в репозитории побайтно не изменился + +### Requirement: Чистый HOME без ~/.claude проходит установку +Установка, bootstrap и skill `role` MUST работать на машине, где Claude Code +никогда не устанавливался. + +- `--global --skills --roles` с явным источником superpowers в пустом `HOME` + MUST создавать `$DSH_HOME/{AGENTS.md,skills/using-superpowers/SKILL.md,skills/role/SKILL.md,skills/sdd/SKILL.md,roles/dev.md}`. +- bootstrap MUST резолвить скилл из `$DSH_HOME/skills` при `home` этого HOME и + MUST NOT обращаться к `~/.claude`. +- Резолвер ролей MUST находить `$DSH_HOME/roles` при `home` этого HOME. + +#### Scenario: Установка в чистый HOME +- **WHEN** `HOME` — пустой temp-каталог, `DSH_HOME=$HOME/.dsh`, + `--superpowers-from` указывает на каталог superpowers +- **THEN** `--global --skills --roles` завершается кодом 0, все перечисленные + файлы на месте, `--check` зелёный по skills и roles + +#### Scenario: Bootstrap в чистом HOME +- **WHEN** `prepareBootstrap({ home: })` вызывается после установки +- **THEN** выбранный путь — `$HOME/.dsh/skills/using-superpowers/SKILL.md`, + текст содержит маркер ``, предупреждений о + legacy нет -- 2.47.2 From 05178869fb5c580fb3d167cf5932f87a185d98d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:10:52 +0400 Subject: [PATCH 2/7] feat(dsh): install superpowers from an explicit source (#49) --- dsh/README.md | 13 ++ dsh/install.sh | 157 +++++++++++--- dsh/tests/install.test.mjs | 203 ++++++++++++++++++ openspec/changes/dsh-install-source/design.md | 5 +- .../specs/dsh-install/spec.md | 3 +- 5 files changed, 353 insertions(+), 28 deletions(-) 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 они не удаляются. -- 2.47.2 From 60d68464a9cc6d132c8f8b35246247af033073a3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:11:31 +0400 Subject: [PATCH 3/7] feat(dsh): DSH-owned role paths in installed instructions (#49) --- dsh/install.sh | 15 +++++++++--- dsh/tests/install.test.mjs | 24 +++++++++++++++++++ .../specs/dsh-install/spec.md | 8 ++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 1208114..050157e 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -266,11 +266,20 @@ install_global() { echo "The dsh-harness bundle enforces guard-main/require-plan and provides the /role command." echo "DSH adapter: the model loads roles with skill role; /role is the user-facing command." echo "DSH adapter: there is no SessionStart role hint; infer the role from the first message." - echo "DSH adapter: role files are ~/.dsh/roles, then ~/.claude/roles, then $ROOT/roles." - echo "DSH adapter: bootstrap prefers ~/.dsh/skills; the ~/.claude cache is a fallback." + echo "DSH adapter: role files are ~/.dsh/roles (installed by --roles); legacy Claude Code role locations are a migration fallback." + echo "DSH adapter: superpowers skills are ~/.dsh/skills (installed by --skills from --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers); legacy Claude Code locations are a migration fallback." + echo "DSH adapter: a project CLAUDE.md/AGENTS.md is the instruction-file format of this harness; it does not imply a Claude Code runtime." echo "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore." echo - cat "$ROOT/global/CLAUDE.md" + # The shared global/CLAUDE.md is also read by Claude Code and must not + # change; only the installed DSH copy points at the DSH role layer. + python3 - "$ROOT/global/CLAUDE.md" <<'PY' +import sys +from pathlib import Path + +text = Path(sys.argv[1]).read_text(encoding="utf-8") +sys.stdout.write(text.replace("~/.claude/roles", "~/.dsh/roles")) +PY } > "$target" echo "written: $target" } diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 29ea9e8..ea06e5b 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -279,3 +279,27 @@ test('--check fails on an invalid explicit source', () => { cleanup(home) } }) + +test('--global installs DSH role paths and explains the instruction format', () => { + const home = tempDir() + try { + const globalSource = join(here, '..', '..', 'global', 'CLAUDE.md') + const before = readFileSync(globalSource, 'utf8') + const run = runInstaller(home, ['--global']) + assert.equal(run.status, 0, run.stdout + run.stderr) + + const agents = readFileSync(join(home, '.dsh', 'AGENTS.md'), 'utf8') + assert.match(agents, /~\/\.dsh\/roles\/dev\.md/) + assert.doesNotMatch(agents, /~\/\.claude/) + assert.match(agents, /superpowers skills are ~\/\.dsh\/skills/) + assert.match(agents, /instruction-file format of this harness/) + assert.match(agents, /does not imply a Claude Code runtime/) + assert.equal(readFileSync(globalSource, 'utf8'), before, 'global/CLAUDE.md must stay untouched') + + const second = runInstaller(home, ['--global']) + assert.equal(second.status, 0, second.stdout + second.stderr) + assert.match(second.stdout, /backup: /) + } finally { + cleanup(home) + } +}) 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 57ad9a8..522efad 100644 --- a/openspec/changes/dsh-install-source/specs/dsh-install/spec.md +++ b/openspec/changes/dsh-install-source/specs/dsh-install/spec.md @@ -109,8 +109,9 @@ SHALL оставаться fallback'ом для машин, где `--skills --r Claude Code. - Строки `~/.claude/roles` в установленной копии MUST заменяться на - `~/.dsh/roles`; `global/CLAUDE.md` в репозитории MUST NOT меняться (его - читает Claude Code). + `~/.dsh/roles`, а adapter-шапка MUST NOT называть пути `~/.claude`: в + установленном файле не остаётся ни одного такого пути. `global/CLAUDE.md` в + репозитории MUST NOT меняться (его читает Claude Code). - Шапка установленного файла MUST говорить, что проектный `CLAUDE.md` / `AGENTS.md` — это формат файлов инструкций DSH, а не запущенный Claude Code, и что роли лежат в `~/.dsh/roles` (`--roles`), а superpowers-скиллы — в @@ -119,7 +120,8 @@ Claude Code. #### Scenario: Пути ролей после установки - **WHEN** выполнен `--global` во временный `DSH_HOME` -- **THEN** в `AGENTS.md` есть `~/.dsh/roles/dev.md` и нет `~/.claude/roles` +- **THEN** в `AGENTS.md` есть `~/.dsh/roles/dev.md` и нет ни одного пути + `~/.claude` #### Scenario: global/CLAUDE.md не тронут - **WHEN** выполнен `--global` -- 2.47.2 From 3bfcc583bb2a2861f1310cd37fe8e2fa2ddd5088 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:12:21 +0400 Subject: [PATCH 4/7] feat(dsh): warn about legacy ~/.claude sources at runtime (#49) --- dsh/PORT.md | 48 +++++++++++++++++++++++++++--------- dsh/README.md | 16 +++++++----- dsh/bundle/index.js | 4 +++ dsh/bundle/lib/bootstrap.js | 4 +++ dsh/bundle/lib/legacy.js | 35 ++++++++++++++++++++++++++ dsh/tests/bootstrap.test.mjs | 33 +++++++++++++++++++++++++ dsh/tests/role.test.mjs | 13 ++++++++++ 7 files changed, 135 insertions(+), 18 deletions(-) create mode 100644 dsh/bundle/lib/legacy.js diff --git a/dsh/PORT.md b/dsh/PORT.md index db4d5f6..858f3b9 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -81,6 +81,13 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read - **Full installer**: `install.sh` prepares global instructions and skills; the bundle itself is installed by `plugin_manager` because the Desktop profile is Electron-managed. +- **Vendored superpowers**: the install-time source is explicit + (`--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or a clone in + `~/.dsh/superpowers`); a copy inside this repository is rejected because + upstream moves independently (change `dsh-install-source`, D-1). Removing the + legacy `~/.claude` fallbacks is deferred until every machine has rerun + `--skills --roles`; until then both runtime and installer warn when they use + one. ## 4. Architecture @@ -162,10 +169,10 @@ for the repository layout: roleCommand: true bootstrapSubagents: false hooksDir: '' # empty -> bundle/hooks -> ../../hooks - rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/ + rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ projectRolesDir: '' # empty -> /.claude/roles roleNames: [dev, admin, analyst, qa] - bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache + bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then legacy Claude Code paths toolMappingPath: '' # empty -> bundle/dsh-tools.md bootstrapMaxBytes: 65536 ``` @@ -200,7 +207,14 @@ with "requires ... in the Host scope". `~/.dsh/skills`, `~/.agents/skills`, the newest `~/.claude/plugins/cache/claude-plugins-official/superpowers/*/`, then `~/.claude/skills`. The DSH copy wins because `dsh/install.sh --skills` copies -real files there; the Claude cache is only a fallback. +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. + +Install-time source resolution is separate and stricter (change +`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` → +`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An +invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a +fresh install and a `WARN` when `~/.dsh/skills` already holds the skills. ### OpenSpec skills @@ -230,18 +244,21 @@ openspec archive -y ## 6. Install and verify Prerequisites: the `claude-harness` checkout, a superpowers source, DSH -0.2.1-rc.3, `python3`, `git`, Node. Claude Code itself is optional after -`dsh/install.sh --skills --roles` has copied the skills and roles into -`~/.dsh`; the hooks come from this checkout. +0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the +superpowers source once with +`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`, +then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this +checkout. ```bash # 1. Static checks bash dsh/install.sh --check # 2. Global instructions, skills, and roles (run from a terminal; writes -# outside the session workspace) +# outside the session workspace). The superpowers source comes from +# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers. bash dsh/install.sh --global -bash dsh/install.sh --skills +bash dsh/install.sh --skills # or: --skills --superpowers-from DIR bash dsh/install.sh --roles # or all three at once: @@ -344,10 +361,13 @@ profile was removed; the real `desktop` profile was not touched. - **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 `dsh/subagent-models.yml`; without it every dispatch runs on the session model. -- **`~/.claude` dependence.** `dsh/install.sh --skills --roles` copies skills - and roles into `~/.dsh`; the bootstrap and `/role` prefer those copies, and - the Claude cache/roles are fallbacks. An existing installation must rerun - `--skills --roles` once to become independent of a Claude Code rollback. +- **`~/.claude` dependence.** No longer needed for install, bootstrap, or + roles: the install-time source is explicit, and `--skills --roles` copy real + files into `~/.dsh`. The Claude Code locations remain a **legacy migration + fallback**; the installer, `--check`, and the runtime warn when they are used + (runtime — once per process). An existing installation must rerun + `--skills --roles` once to stop relying on them; removal of the fallbacks is + a separate task. - **Superpowers porting rules.** The upstream guide requires a session-start bootstrap with no per-session opt-in; `agent/pre-step` satisfies that shape, but a real acceptance transcript is still required before claiming a port. @@ -374,3 +394,7 @@ profile was removed; the real `desktop` profile was not touched. gets its own release cadence. 8. OpenSpec — **done** (change `dsh-openspec`): проектные скиллы, `--openspec`, `--check`; приёмка — в дизайне change. +9. Install without `~/.claude` — **done** (change `dsh-install-source`): + `--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`/`~/.dsh/superpowers`, hard + `FAIL` instead of a silent empty bootstrap, DSH-owned role paths in the + installed `AGENTS.md`, legacy warnings at install and runtime. diff --git a/dsh/README.md b/dsh/README.md index 934d84f..3fa3445 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -140,10 +140,10 @@ repository layout: roleCommand: true bootstrapSubagents: false hooksDir: '' # empty -> bundle/hooks -> ../../hooks - rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/ + rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ projectRolesDir: '' # empty -> /.claude/roles roleNames: [dev, admin, analyst, qa] - bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache + bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then legacy Claude Code paths toolMappingPath: '' # empty -> bundle/dsh-tools.md bootstrapMaxBytes: 65536 ``` @@ -155,10 +155,14 @@ repository layout: - `bundle/hooks` is a symlink for local-directory installation. A published bundle needs a real copy of `hooks/` (already listed in `package.json` `files`). -- `using-superpowers` is read from `~/.dsh/skills` after `--skills`; the Claude - plugin cache is only a fallback, so a partial Claude Code rollback does not - break the bootstrap. Set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for - another layout. +- `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The + install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or + `~/.dsh/superpowers`; Claude Code locations are a **legacy migration + fallback** and log one warning per process when used, so a partial Claude + Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or + `DSH_SUPERPOWERS_SKILL` for another layout. +- Roles resolve `~/.dsh/roles` first; `~/.claude/roles` and the repository + `roles/` are fallbacks. After `--roles` the runtime never needs `~/.claude`. - Role loading is model-driven through the `role` skill; there is no automatic `agent/pre-step` injection. If the model skips the skill, use the `/role` command or ask it to load the role explicitly. diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 244edc8..4d91a6b 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url' import { registerRoleCommand } from './command-role.js' import { createGuard } from './lib/guards.js' import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js' +import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js' import { createUserMessage } from './lib/message.js' import { DEFAULT_ROLE_NAMES } from './lib/role.js' @@ -93,6 +94,9 @@ export function apply(ctx, rawConfig = {}) { const bundleDir = dirname(fileURLToPath(import.meta.url)) const hooksDir = resolveHooksDir(bundleDir, config.hooksDir) const rolesDir = resolveRolesDir(bundleDir, config.rolesDir) + if (isLegacyClaudePath(rolesDir)) { + warnLegacyOnce(ctx.logger, 'roles', rolesDir, 'roles') + } const guard = createGuard({ hooksDir, logger: ctx.logger }) if (config.enabled && config.guards) { diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js index bb0c84b..99b49f4 100644 --- a/dsh/bundle/lib/bootstrap.js +++ b/dsh/bundle/lib/bootstrap.js @@ -12,6 +12,7 @@ import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { isLegacyClaudePath, warnLegacyOnce } from './legacy.js' const DEFAULT_MAPPING = [ 'Use the tools reported by the harness. In particular:', @@ -141,6 +142,9 @@ export function prepareBootstrap({ logger?.warn?.('[dsh-harness] superpowers bootstrap skill not found; skipping injection') return undefined } + if (isLegacyClaudePath(resolved, home)) { + warnLegacyOnce(logger, 'bootstrap', resolved, 'skills') + } const mappingFile = mappingPath && existsSync(mappingPath) ? mappingPath : '' const key = `${resolved}|${mappingFile}|${maxBytes}` diff --git a/dsh/bundle/lib/legacy.js b/dsh/bundle/lib/legacy.js new file mode 100644 index 0000000..1c82202 --- /dev/null +++ b/dsh/bundle/lib/legacy.js @@ -0,0 +1,35 @@ +/** + * Legacy Claude Code source detection. + * + * After #37/#38 the DSH runtime prefers `~/.dsh` for skills and roles. The + * `~/.claude` locations stay as a migration fallback: a machine that has not + * rerun `dsh/install.sh --skills --roles` keeps working, but the agent should + * say so once instead of serving legacy paths silently. + */ + +import { homedir } from 'node:os' +import { join, sep } from 'node:path' + +const warned = new Set() + +/** Whether `path` lives under `/.claude` (the legacy source root). */ +export function isLegacyClaudePath(path, home = homedir()) { + if (typeof path !== 'string' || path === '') return false + return path.startsWith(join(home, '.claude') + sep) +} + +/** + * Warn once per process and per kind+path: `agent/pre-step` runs on every + * step, so an unconditional warning would flood the log. + */ +export function warnLegacyOnce(logger, kind, path, flag = kind) { + const key = `${kind}|${path}` + if (warned.has(key)) return + warned.add(key) + logger?.warn?.( + '[dsh-harness] legacy Claude Code source in use for %s: %s; run dsh/install.sh --%s', + kind, + path, + flag, + ) +} diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs index 4764c6d..1af85ce 100644 --- a/dsh/tests/bootstrap.test.mjs +++ b/dsh/tests/bootstrap.test.mjs @@ -112,3 +112,36 @@ test('resolveBootstrapSkill prefers the DSH copy over the Claude cache', () => { cleanup(home) } }) + +test('prepareBootstrap warns once when the legacy ~/.claude skill is used', () => { + const home = tempDir() + try { + const legacy = join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md') + write(legacy, '---\nname: using-superpowers\n---\nlegacy body\n') + const warnings = [] + const logger = { warn: (...args) => warnings.push(args) } + + const first = prepareBootstrap({ home, env: {}, logger }) + const second = prepareBootstrap({ home, env: {}, logger }) + + assert.equal(first.path, legacy) + assert.ok(second) + assert.equal(warnings.length, 1, 'one warning per process, not per step') + assert.match(String(warnings[0][0]), /legacy Claude Code source/) + assert.deepEqual(warnings[0].slice(-3), ['bootstrap', legacy, 'skills']) + } finally { + cleanup(home) + } +}) + +test('prepareBootstrap stays silent for the DSH-owned copy', () => { + const home = tempDir() + try { + write(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), '---\nname: x\n---\nbody\n') + const warnings = [] + prepareBootstrap({ home, env: {}, logger: { warn: (...args) => warnings.push(args) } }) + assert.equal(warnings.length, 0) + } finally { + cleanup(home) + } +}) diff --git a/dsh/tests/role.test.mjs b/dsh/tests/role.test.mjs index 79f705a..88408d1 100644 --- a/dsh/tests/role.test.mjs +++ b/dsh/tests/role.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { join } from 'node:path' import test from 'node:test' import { resolveRolesDir } from '../bundle/index.js' +import { isLegacyClaudePath } from '../bundle/lib/legacy.js' import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js' import { cleanup, tempDir, write } from './helpers.mjs' @@ -108,3 +109,15 @@ test('listRoles reports both layers', () => { cleanup(root) } }) + +test('isLegacyClaudePath detects ~/.claude paths only', () => { + const home = tempDir() + try { + assert.equal(isLegacyClaudePath(join(home, '.claude', 'roles', 'dev.md'), home), true) + assert.equal(isLegacyClaudePath(join(home, '.dsh', 'roles', 'dev.md'), home), false) + assert.equal(isLegacyClaudePath(join(home, '.claude-other', 'x'), home), false) + assert.equal(isLegacyClaudePath('', home), false) + } finally { + cleanup(home) + } +}) -- 2.47.2 From 12f19b1de5b5814b4462ac46cf65153b250851d5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:14:19 +0400 Subject: [PATCH 5/7] =?UTF-8?q?docs(openspec):=20=D0=BF=D1=80=D0=B8=D1=91?= =?UTF-8?q?=D0=BC=D0=BA=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20~/.claude=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-install-source/design.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openspec/changes/dsh-install-source/design.md b/openspec/changes/dsh-install-source/design.md index b5dde46..1634172 100644 --- a/openspec/changes/dsh-install-source/design.md +++ b/openspec/changes/dsh-install-source/design.md @@ -149,3 +149,23 @@ Issue: #49. Спека: `openspec/changes/dsh-install-source/specs/dsh-install/s - Temp `HOME` без `~/.claude`: `--global --skills --roles` с `--superpowers-from`; `--check`; `prepareBootstrap({home})` — маркер на месте. - Живой fresh-session smoke без `~/.claude` — за владельцем (в PR отмечено). + +### Приёмка: прогон 24.10.2026 + +- `openspec validate dsh-install-source --strict` → `Change 'dsh-install-source' is valid`. +- `bash dsh/tests/run.sh` → 61/61 pass (до изменения — 57). +- Temp `HOME` без `~/.claude`: копия реальных superpowers 7.0.0 в + `/superpowers`, `--global --skills --roles --superpowers-from` → 15 + superpowers-скиллов, 2 локальных (`role`, `sdd`), 4 роли; `$HOME/.claude` не + создан; `prepareBootstrap({ home })` выбрал + `~/.dsh/skills/using-superpowers/SKILL.md`, маркер + ``; `--check` — `ok: dsh skills`, + `ok: role skill`, `ok: roles`, `WARN: no superpowers source` (источник был + временным каталогом, копии на месте). +- Машина владельца: `--check` — `WARN: legacy superpowers source + (~/.claude/plugins/cache/.../7.0.0/skills)`, `ok: dsh skills`, `ok: roles`. В + установленном `~/.dsh/AGENTS.md` ещё три пути `~/.claude/roles` — нужен + повторный `--global` (ручной шаг, отмечен в PR). +- Fresh-session smoke без `~/.claude` — за владельцем: юнит-уровень + (`prepareBootstrap` с temp HOME) пройден, реальную сессию DSH поднимает + владелец. -- 2.47.2 From ab458b58d26a16c041d4005a4cb7ab1f9c634483 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:14:58 +0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(dsh):=20=D1=8F=D1=81=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B5=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B5?= =?UTF-8?q?=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 2 +- dsh/tests/install.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 050157e..2e7b7a2 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -138,7 +138,7 @@ explicit_source_name() { } report_invalid_explicit_source() { - echo "FAIL: explicit superpowers source $(explicit_source_name) has no using-superpowers/SKILL.md (expected /skills/... or /...)" >&2 + echo "FAIL: explicit superpowers source is invalid: $(explicit_source_name) has no using-superpowers/SKILL.md (expected /skills/... or /...)" >&2 } check() { diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index ea06e5b..5922c75 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -231,7 +231,7 @@ test('an invalid explicit source fails without touching the legacy cache', () => 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.match(out, /FAIL: explicit superpowers source is invalid: .* has no using-superpowers\/SKILL\.md/) assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false) } finally { cleanup(home) -- 2.47.2 From 4054e13806c30107d8e338745346487a49d8601c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:20:29 +0400 Subject: [PATCH 7/7] =?UTF-8?q?fix(dsh):=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20legacy-=D0=BA?= =?UTF-8?q?=D1=8D=D1=88=20=D0=B8=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20?= =?UTF-8?q?--superpowers-from=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 21 ++++++++++++++++--- dsh/tests/install.test.mjs | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 2e7b7a2..989da7f 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -28,6 +28,7 @@ DO_SKILLS=0 DO_ROLES=0 DO_OPENSPEC=0 SUPERPOWERS_FROM="" +SUPERPOWERS_FROM_SET=0 CLONE_HINT="git clone --depth 1 https://github.com/obra/superpowers $DSH_HOME/superpowers" usage() { @@ -73,15 +74,26 @@ while [ "$#" -gt 0 ]; do exit 2 fi SUPERPOWERS_FROM="$2" + SUPERPOWERS_FROM_SET=1 shift ;; - --superpowers-from=*) SUPERPOWERS_FROM="${1#*=}" ;; + --superpowers-from=*) + SUPERPOWERS_FROM="${1#*=}" + SUPERPOWERS_FROM_SET=1 + ;; -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; esac shift done +# An explicit flag with an empty value is a typo, not "use the default source": +# falling through to legacy would mask exactly the misconfiguration D-2 forbids. +if [ "$SUPERPOWERS_FROM_SET" = 1 ] && [ -z "$SUPERPOWERS_FROM" ]; then + echo "option --superpowers-from needs a non-empty directory" >&2 + exit 2 +fi + # Normalize a superpowers source: accept the repo root (with skills/) or the # skills directory itself. Prints the skills directory, returns 1 otherwise. normalize_superpowers_dir() { @@ -117,8 +129,11 @@ superpowers_source() { return 0 fi legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" - if [ -n "$legacy" ] && [ -d "$legacy" ]; then - printf '%s\tlegacy\n' "$legacy" + # A cache directory without using-superpowers is a broken cache, not a source: + # accepting it would copy 0 skills and report success (the silent empty + # bootstrap this change removes). + if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then + printf '%s\tlegacy\n' "$dir" return 0 fi if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 5922c75..e1623cf 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -303,3 +303,44 @@ test('--global installs DSH role paths and explains the instruction format', () cleanup(home) } }) + +test('a legacy cache without using-superpowers is not a source', () => { + const home = tempDir() + try { + const cache = join( + home, + '.claude', + 'plugins', + 'cache', + 'claude-plugins-official', + 'superpowers', + '7.0.0', + 'skills', + ) + write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n') + const run = runInstaller(home, ['--skills']) + const out = run.stdout + run.stderr + assert.equal(run.status, 1, out) + assert.match(out, /FAIL: no superpowers source/) + assert.doesNotMatch(out, /copied 0 superpowers/) + assert.equal(existsSync(join(home, '.dsh', 'skills', 'brainstorming')), false) + } finally { + cleanup(home) + } +}) + +test('an empty --superpowers-from is rejected, not ignored', () => { + const home = tempDir() + try { + makeLegacyCache(home) + for (const args of [['--skills', '--superpowers-from='], ['--skills', '--superpowers-from', '']]) { + const run = runInstaller(home, args) + const out = run.stdout + run.stderr + assert.equal(run.status, 2, out) + assert.match(out, /needs a non-empty directory/) + assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false) + } + } finally { + cleanup(home) + } +}) -- 2.47.2