Merge pull request 'DSH: установка без зависимости от ~/.claude (#49)' (#64) from feat/49-dsh-install-source into main
This commit is contained in:
commit
9cae7e1234
293
docs/superpowers/plans/2026-10-24-dsh-install-source.md
Normal file
293
docs/superpowers/plans/2026-10-24-dsh-install-source.md
Normal file
@ -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<n>.<k>`; решения в этом плане уже с маркерами `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 <dir>` (и `--superpowers-from=<dir>`), env `DSH_SUPERPOWERS_SOURCE`;
|
||||
- bash-функции `normalize_superpowers_dir <dir>` (печатает `skills`-каталог или rc 1) и `superpowers_source` (печатает `<path>\t<explicit|pinned|legacy>`, rc 1 если источника нет);
|
||||
- вывод `--skills`: `copied N superpowers skills into <dest> (source: <path>)`; при legacy — строка `WARN: legacy superpowers source <path>; prefer --superpowers-from or ~/.dsh/superpowers`;
|
||||
- вывод `--check`: `ok: superpowers source (<path>)` / `WARN: legacy superpowers source (<path>); 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; источник: <home>/src/superpowers/skills/using-superpowers/SKILL.md
|
||||
// + brainstorming/SKILL.md; запуск: --skills --superpowers-from <home>/src/superpowers
|
||||
// assert: status 0; stdout /source: <...>/src\/superpowers\/skills/;
|
||||
// <dshHome>/skills/using-superpowers/SKILL.md и role/sdd на месте;
|
||||
// existsSync(<home>/.claude) === false
|
||||
|
||||
test('--superpowers-from accepts the skills directory itself', ...)
|
||||
// тот же fixture, флаг указывает на <home>/src/superpowers/skills; status 0
|
||||
|
||||
test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', ...)
|
||||
// два разных fixture-источника (A: body "a", B: body "b"); env=B, флаг=A;
|
||||
// в <dshHome>/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 с <dshHome>/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/;
|
||||
// <dshHome>/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 с <dshHome>/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`; печатать `<path>\t<kind>`; явный невалидный источник — `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: <path>)` в итоговой строке.
|
||||
- `check`: `ok: dsh skills` оставить; добавить ветку источника (`ok`/`WARN legacy`/`WARN none` по kind); legacy-источник не делает `check` падающим.
|
||||
- Dispatch: `install_skills || status=1; install_local_skills` — локальные скиллы ставятся всегда.
|
||||
- `usage`: строка `--superpowers-from <dir>` с описанием трёх источников.
|
||||
|
||||
- [ ] **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 <dir>`), затем `--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` — истина для `<home>/.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`) не затронут.
|
||||
48
dsh/PORT.md
48
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 -> <cwd>/.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 <id> -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.
|
||||
|
||||
@ -33,6 +33,13 @@ risks.
|
||||
# Static check
|
||||
bash dsh/install.sh --check
|
||||
|
||||
# Superpowers source: get it once per machine. install.sh never downloads
|
||||
# anything and no longer depends on ~/.claude.
|
||||
git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers
|
||||
# ...or point at any checkout of the superpowers repo / its skills directory:
|
||||
bash dsh/install.sh --skills --superpowers-from /path/to/superpowers
|
||||
# ...or export DSH_SUPERPOWERS_SOURCE=/path/to/superpowers
|
||||
|
||||
# Global instructions, superpowers skills, local role skill, and roles
|
||||
# (run from a terminal; these write outside the session workspace)
|
||||
bash dsh/install.sh --global
|
||||
@ -47,6 +54,12 @@ bash <harness>/dsh/install.sh --openspec
|
||||
# or ask the agent to call plugin_manager install_bundle with that target.
|
||||
```
|
||||
|
||||
`--skills` resolves superpowers in this order: `--superpowers-from` →
|
||||
`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache
|
||||
(legacy, warns). A clean HOME with no source and no installed copy fails with
|
||||
the clone command above instead of pretending success; the local `role` and
|
||||
`sdd` skills are copied either way.
|
||||
|
||||
Do not hand-edit `$DSH_HOME/profiles/desktop/package.json` or
|
||||
`cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin
|
||||
manager owns those writes.
|
||||
@ -127,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 -> <cwd>/.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
|
||||
```
|
||||
@ -142,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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
4
dsh/bundle/lib/bootstrap.js
vendored
4
dsh/bundle/lib/bootstrap.js
vendored
@ -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}`
|
||||
|
||||
35
dsh/bundle/lib/legacy.js
Normal file
35
dsh/bundle/lib/legacy.js
Normal file
@ -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 `<home>/.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,
|
||||
)
|
||||
}
|
||||
187
dsh/install.sh
187
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 <dir>]
|
||||
# bash dsh/install.sh --roles
|
||||
# bash dsh/install.sh --all
|
||||
# bash dsh/install.sh --openspec
|
||||
@ -27,17 +27,28 @@ DO_GLOBAL=0
|
||||
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() {
|
||||
cat <<EOF
|
||||
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
|
||||
bash dsh/install.sh --skills --superpowers-from <dir>
|
||||
|
||||
--check verify python3, git, node, hooks, bundle, skills, and roles
|
||||
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
|
||||
--skills copy superpowers skills and local dsh/skills into \$DSH_HOME/skills
|
||||
--roles copy roles/ into \$DSH_HOME/roles
|
||||
--all --check + --global + --skills + --roles, then print the bundle step
|
||||
--openspec add the agents skill adapter to the current OpenSpec project
|
||||
--check verify python3, git, node, hooks, bundle, skills, and roles
|
||||
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
|
||||
--skills copy superpowers and local dsh/skills into \$DSH_HOME/skills
|
||||
--roles copy roles/ into \$DSH_HOME/roles
|
||||
--all --check + --global + --skills + --roles, then print the bundle step
|
||||
--openspec add the agents skill adapter to the current OpenSpec project
|
||||
--superpowers-from DIR copy superpowers skills from DIR (the repo root or its skills/)
|
||||
|
||||
Superpowers source, first match wins. The script never downloads anything:
|
||||
1. --superpowers-from DIR
|
||||
2. \$DSH_SUPERPOWERS_SOURCE
|
||||
3. \$DSH_HOME/superpowers -- get it once: $CLONE_HINT
|
||||
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns)
|
||||
|
||||
Bundle installation itself is done in DSH:
|
||||
Plugins -> install a bundle from $BUNDLE
|
||||
@ -49,29 +60,102 @@ 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"
|
||||
SUPERPOWERS_FROM_SET=1
|
||||
shift
|
||||
;;
|
||||
--superpowers-from=*)
|
||||
SUPERPOWERS_FROM="${1#*=}"
|
||||
SUPERPOWERS_FROM_SET=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
|
||||
# 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() {
|
||||
local dir="${1:-}"
|
||||
[ -n "$dir" ] || return 1
|
||||
if [ -f "$dir/skills/using-superpowers/SKILL.md" ]; then
|
||||
printf '%s\n' "$dir/skills"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$dir/using-superpowers/SKILL.md" ]; then
|
||||
printf '%s\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Print "<skills-dir>\t<explicit|pinned|legacy>". Returns 2 when an explicitly
|
||||
# configured source is invalid (never fall back silently), 1 when none is found.
|
||||
superpowers_source() {
|
||||
local dir legacy
|
||||
if [ -n "$SUPERPOWERS_FROM" ]; then
|
||||
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
|
||||
printf '%s\texplicit\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
|
||||
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
|
||||
printf '%s\texplicit\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
|
||||
printf '%s\tpinned\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
|
||||
# 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
|
||||
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 is invalid: $(explicit_source_name) has no using-superpowers/SKILL.md (expected <dir>/skills/... or <dir>/...)" >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
local failed=0
|
||||
for cmd in python3 git node; do
|
||||
@ -134,12 +218,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"
|
||||
@ -179,25 +281,50 @@ 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"
|
||||
}
|
||||
|
||||
install_skills() {
|
||||
mkdir -p "$DSH_HOME/skills"
|
||||
local skills
|
||||
if ! skills="$(superpowers_skills)"; then
|
||||
echo "WARN: superpowers skills not found; nothing copied" >&2
|
||||
return 0
|
||||
local line="" rc=0 source kind count=0
|
||||
line="$(superpowers_source)" || rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
report_invalid_explicit_source
|
||||
return 1
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
|
||||
echo "WARN: no superpowers source found; keeping installed skills in $DSH_HOME/skills" >&2
|
||||
echo "WARN: next time run: $CLONE_HINT" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2
|
||||
echo "FAIL: get the source once: $CLONE_HINT" >&2
|
||||
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2
|
||||
return 1
|
||||
fi
|
||||
kind="${line##*$'\t'}"
|
||||
source="${line%%$'\t'*}"
|
||||
if [ "$kind" = legacy ]; then
|
||||
echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2
|
||||
fi
|
||||
local count=0
|
||||
local dir name dest
|
||||
for dir in "$skills"/*; do
|
||||
for dir in "$source"/*; do
|
||||
[ -f "$dir/SKILL.md" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
dest="$DSH_HOME/skills/$name"
|
||||
@ -205,7 +332,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 +448,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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -76,3 +76,271 @@ 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 is invalid: .* 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)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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,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
openspec/changes/dsh-install-source/.openspec.yaml
Normal file
2
openspec/changes/dsh-install-source/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
171
openspec/changes/dsh-install-source/design.md
Normal file
171
openspec/changes/dsh-install-source/design.md
Normal file
@ -0,0 +1,171 @@
|
||||
# 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). Исключение —
|
||||
невалидный **явный** источник (`--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`):
|
||||
это ошибка конфигурации пользователя, `--check` даёт `FAIL`, иначе опечатка
|
||||
выглядит как «источника нет».
|
||||
|
||||
## 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 отмечено).
|
||||
|
||||
### Приёмка: прогон 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 в
|
||||
`<tmp>/superpowers`, `--global --skills --roles --superpowers-from` → 15
|
||||
superpowers-скиллов, 2 локальных (`role`, `sdd`), 4 роли; `$HOME/.claude` не
|
||||
создан; `prepareBootstrap({ home })` выбрал
|
||||
`~/.dsh/skills/using-superpowers/SKILL.md`, маркер
|
||||
`<!-- DSH-HARNESS-BOOTSTRAP:412d797495fc8765 -->`; `--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 поднимает
|
||||
владелец.
|
||||
68
openspec/changes/dsh-install-source/proposal.md
Normal file
68
openspec/changes/dsh-install-source/proposal.md
Normal file
@ -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/<r>.md` (унаследованы из `global/CLAUDE.md`, общего с CC):
|
||||
adapter и skill `role` их перекрывают, но текст путает агента и человека.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Источник superpowers задаётся при установке.** `dsh/install.sh --skills`
|
||||
разрешает источник в порядке: `--superpowers-from <dir>` → `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 (<path>)` для
|
||||
явного/пиннинга, `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.
|
||||
150
openspec/changes/dsh-install-source/specs/dsh-install/spec.md
Normal file
150
openspec/changes/dsh-install-source/specs/dsh-install/spec.md
Normal file
@ -0,0 +1,150 @@
|
||||
# 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 <dir>` → `$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=<dir>` и флаг `--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 (<path>)`
|
||||
|
||||
### 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` с подсказкой, невалидный явный источник — `FAIL` и код 1
|
||||
(пользователь задал его сам, подсказка «нет источника» здесь неверна).
|
||||
- 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`, а adapter-шапка MUST NOT называть пути `~/.claude`: в
|
||||
установленном файле не остаётся ни одного такого пути. `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`
|
||||
|
||||
#### 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: <temp HOME> })` вызывается после установки
|
||||
- **THEN** выбранный путь — `$HOME/.dsh/skills/using-superpowers/SKILL.md`,
|
||||
текст содержит маркер `<!-- DSH-HARNESS-BOOTSTRAP:... -->`, предупреждений о
|
||||
legacy нет
|
||||
Loading…
x
Reference in New Issue
Block a user