feat(dsh): отвязать skills и roles от ~/.claude (#37)

This commit is contained in:
Andrey Epifancev 2026-10-24 16:10:39 +04:00
parent f422119090
commit 306ec2e174
10 changed files with 260 additions and 52 deletions

View File

@ -0,0 +1,62 @@
# DSH decouple from ~/.claude — план
Ветка: `feat/37-dsh-decouple` (issue #37).
Спека/дизайн: `dsh/PORT.md`.
## Цель
Сделать DSH-порт независимым от установки Claude Code: superpowers skills и
роли лежат в `~/.dsh`, а `~/.claude` используется только как fallback.
## Scope
1. `dsh/install.sh`: копировать skills (не symlink), добавить `--roles`,
включить роли в `--all`.
2. `dsh/bundle/index.js`: `resolveRolesDir` — `~/.dsh/roles` → `~/.claude/roles`
→ repo `roles/`.
3. `dsh/bundle/lib/bootstrap.js`: `~/.dsh/skills` раньше кэша `~/.claude`.
4. Тесты: install-копирование, приоритет ролей, приоритет bootstrap.
5. README/PORT: новый порядок установки и fallback.
## Non-goals
- Проектные роли `<cwd>/.dsh/roles` — отдельная задача.
- Vendor superpowers в репозиторий; пока копируем из установленного кэша.
- OpenSpec, SDD, briefing.
## Задачи
- [x] **Task 1: install.sh копирует skills и роли**
- Files: `dsh/install.sh`
- `--skills`: `rm -rf` dest + `cp -R` source, не `ln -sfn`.
- `--roles`: `cp` `$ROOT/roles/*.md` в `$DSH_HOME/roles`.
- `--all` включает `--roles`.
- Проверка: `bash dsh/install.sh --skills --roles` с temp `DSH_HOME`.
- [x] **Task 2: приоритет ~/.dsh/roles**
- Files: `dsh/bundle/index.js`, `dsh/bundle/lib/role.js`
- `resolveRolesDir(bundleDir, configured, home)`: `~/.dsh/roles` →
`~/.claude/roles` → repo.
- `role.js` default `rolesDir` = `~/.dsh/roles`.
- Проверка: unit-тест с temp home, где оба каталога существуют.
- [x] **Task 3: приоритет ~/.dsh/skills**
- Files: `dsh/bundle/lib/bootstrap.js`
- `superpowersCandidates`: env → `~/.dsh/skills` → `~/.agents/skills` →
`~/.claude` cache → `~/.claude/skills`.
- Проверка: unit-тест с temp home, где есть и `.dsh`, и `.claude` cache.
- [x] **Task 4: тест install.sh**
- Files: `dsh/tests/install.test.mjs`
- Fake superpowers cache в temp `HOME`; запуск `--skills --roles`;
проверка, что skill — real directory, роли скопированы; повторный запуск.
- [x] **Task 5: документация**
- Files: `dsh/README.md`, `dsh/PORT.md`
- `--roles`, копирование, fallback.
- [x] **Task 6: прогон и коммит**
- `bash dsh/tests/run.sh` — все тесты.
- `bash tests/run.sh` — регрессий нет.
- Commit, push, PR.
## Acceptance
- В temp `HOME` с только `~/.dsh` bootstrap и `/role dev` работают.
- `bash dsh/tests/run.sh` зелёный.
- `dsh/install.sh --all` создаёт real copies, не symlinks.

View File

@ -41,11 +41,12 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read
`bundle/dsh-tools.md`, wraps the result in `<EXTREMELY_IMPORTANT>`, and
injects it once per session. A content-hash marker handles dedupe; scanning
`session.surface.nodes` re-injects after compaction.
3. **`/role`** as a real command (`ctx.commands.register`). It reads the global
`~/.claude/roles/<role>.md` and the project `.claude/roles/<role>.md`, injects
the role text as a user message, and starts a turn for the five-line summary.
Auto-memory is explicitly dropped; the role's "Читать на старте" section is
left to the model.
3. **`/role`** as a real command (`ctx.commands.register`). It reads
`~/.dsh/roles/<role>.md`, falls back to `~/.claude/roles/<role>.md`, then to
the repository's `roles/`, and reads the project `.claude/roles/<role>.md`.
It injects the role text as a user message and starts a turn for the
five-line summary. Auto-memory is explicitly dropped; the role's "Читать на
старте" section is left to the model.
4. **Display metadata and an optional CodeGraph MCP overlay** so the bundle can
be installed through the Plugins page and codegraph stays available.
@ -149,31 +150,39 @@ for the repository layout:
roleCommand: true
bootstrapSubagents: false
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
rolesDir: '' # empty -> installed ~/.claude/roles, else repo roles/
rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/
projectRolesDir: '' # empty -> <cwd>/.claude/roles
roleNames: [dev, admin, analyst, qa]
bootstrapSkillPath: '' # empty -> newest superpowers plugin cache
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
toolMappingPath: '' # empty -> bundle/dsh-tools.md
bootstrapMaxBytes: 65536
```
`bootstrapSkillPath` resolution order: explicit path, `DSH_SUPERPOWERS_SKILL`,
the newest `~/.claude/plugins/cache/claude-plugins-official/superpowers/*/`,
then `~/.claude/skills`, `~/.dsh/skills`, and `~/.agents/skills`.
`~/.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.
## 6. Install and verify
Prerequisites: `claude-harness` installed in `~/.claude` (hooks, roles,
skills), superpowers 6.4.x, DSH 0.2.1-rc.3, `python3`, `git`, Node.
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.
```bash
# 1. Static checks
bash dsh/install.sh --check
# 2. Global instructions and skills (run from a terminal; writes outside the
# session workspace)
# 2. Global instructions, skills, and roles (run from a terminal; writes
# outside the session workspace)
bash dsh/install.sh --global
bash dsh/install.sh --skills
bash dsh/install.sh --roles
# or all three at once:
bash dsh/install.sh --global --skills --roles
# 3. Install the bundle in DSH:
# Plugins -> install bundle from dsh/bundle
@ -260,6 +269,10 @@ profile was removed; the real `desktop` profile was not touched.
`<SUBAGENT-STOP>` makes that harmless but token-expensive.
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. Map them to live
provider/model routes before porting the SDD text.
- **`~/.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.
- **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.

View File

@ -24,10 +24,11 @@ separate Cordis bundle plus a thin install helper.
# Static check
bash dsh/install.sh --check
# Global instructions and superpowers skills
# Global instructions, superpowers skills, and roles
# (run from a terminal; these write outside the session workspace)
bash dsh/install.sh --global
bash dsh/install.sh --skills
bash dsh/install.sh --roles
# Install the bundle in DSH:
# Plugins -> install a bundle from dsh/bundle
@ -104,10 +105,10 @@ repository layout:
roleCommand: true
bootstrapSubagents: false
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
rolesDir: '' # empty -> installed ~/.claude/roles, else repo roles/
rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/
projectRolesDir: '' # empty -> <cwd>/.claude/roles
roleNames: [dev, admin, analyst, qa]
bootstrapSkillPath: '' # empty -> newest superpowers plugin cache
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
toolMappingPath: '' # empty -> bundle/dsh-tools.md
bootstrapMaxBytes: 65536
```
@ -119,8 +120,10 @@ 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` content is read from the installed Claude plugin cache;
set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for another layout.
- `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.
- Model names in roles and superpowers (`sonnet`, `haiku`, `opus`) are not DSH
models. Map them to live provider/model routes before porting SDD.
- SDD is intentionally out of scope for this spike; see

View File

@ -38,14 +38,16 @@ function resolveHooksDir(bundleDir, configured) {
return join(bundleDir, '..', '..', 'hooks')
}
/** Prefer the installed global roles, then the repository's source roles. */
function resolveRolesDir(bundleDir, configured) {
/** Prefer DSH-installed roles, then Claude Code, then the repository source. */
export function resolveRolesDir(bundleDir, configured, home = homedir()) {
if (configured) return configured
const installed = join(homedir(), '.claude', 'roles')
if (existsSync(join(installed, 'dev.md'))) return installed
const dshInstalled = join(home, '.dsh', 'roles')
if (existsSync(join(dshInstalled, 'dev.md'))) return dshInstalled
const claudeInstalled = join(home, '.claude', 'roles')
if (existsSync(join(claudeInstalled, 'dev.md'))) return claudeInstalled
const source = join(bundleDir, '..', '..', 'roles')
if (existsSync(join(source, 'dev.md'))) return source
return installed
return dshInstalled
}

View File

@ -63,6 +63,12 @@ export function superpowersCandidates({ home = homedir(), env = process.env } =
const out = []
if (env.DSH_SUPERPOWERS_SKILL) out.push(env.DSH_SUPERPOWERS_SKILL)
// DSH-owned copies come first: `dsh/install.sh --skills` copies the skill
// bodies into ~/.dsh/skills, so a Claude Code rollback cannot break the
// bootstrap.
out.push(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))
out.push(join(home, '.agents', 'skills', 'using-superpowers', 'SKILL.md'))
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers')
if (existsSync(root)) {
let entries = []
@ -81,12 +87,10 @@ export function superpowersCandidates({ home = homedir(), env = process.env } =
}
out.push(join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md'))
out.push(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))
out.push(join(home, '.agents', 'skills', 'using-superpowers', 'SKILL.md'))
return out
}
/** Resolve the skill file: explicit config, env override, installed plugin cache, then skill roots. */
/** Resolve the skill file: explicit config, env override, DSH-owned copy, then Claude Code fallbacks. */
export function resolveBootstrapSkill({ configuredPath = '', env = process.env, home = homedir() } = {}) {
if (configuredPath && existsSync(configuredPath)) return configuredPath
return superpowersCandidates({ home, env }).find((path) => existsSync(path))

View File

@ -2,10 +2,10 @@
* Role loading for the `/role` command.
*
* The file format stays the claude-harness format: a global layer
* (`~/.claude/roles/<role>.md`) and an optional project layer
* (`<workspace>/.claude/roles/<role>.md`). The project layer wins on conflict.
* Auto-memory is Claude Code-only; this port explicitly drops that step and
* leaves the role's "Читать на старте" section to the model.
* (`~/.dsh/roles/<role>.md`, falling back to `~/.claude/roles`), and an optional
* project layer (`<workspace>/.claude/roles/<role>.md`). The project layer wins
* on conflict. Auto-memory is Claude Code-only; this port explicitly drops that
* step and leaves the role's "Читать на старте" section to the model.
*/
import { existsSync, readFileSync } from 'node:fs'
@ -13,6 +13,7 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
export const DEFAULT_ROLE_NAMES = ['dev', 'admin', 'analyst', 'qa']
const DEFAULT_ROLES_DIR = join(homedir(), '.dsh', 'roles')
/** Parse `/role [name]` without owning any grammar beyond the first word. */
export function parseRole(rawInput, roleNames = DEFAULT_ROLE_NAMES) {
@ -24,7 +25,7 @@ export function parseRole(rawInput, roleNames = DEFAULT_ROLE_NAMES) {
}
/** Absolute paths of both layers for one role. */
export function rolePaths({ role, cwd, rolesDir = join(homedir(), '.claude', 'roles'), projectRolesDir = '' }) {
export function rolePaths({ role, cwd, rolesDir = DEFAULT_ROLES_DIR, projectRolesDir = '' }) {
const globalPath = join(rolesDir, `${role}.md`)
const projectPath = join(projectRolesDir || join(cwd, '.claude', 'roles'), `${role}.md`)
return { globalPath, projectPath }
@ -41,7 +42,7 @@ function readIfPresent(path) {
/** Render the list shown by a bare `/role`. */
export function listRoles({ cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) {
const lines = [
`Роли claude-harness; общий слой: ${rolesDir || join(homedir(), '.claude', 'roles')}`,
`Роли claude-harness; общий слой: ${rolesDir || DEFAULT_ROLES_DIR}`,
`Workspace: ${cwd}`,
'',
]

View File

@ -4,16 +4,18 @@
# This script never installs the Cordis bundle into a profile: the Desktop
# profile is Electron-managed, so the bundle goes through the GUI Plugins page
# or the model-facing `plugin_manager` tool. The script prepares the global
# instructions and the user skill root, then prints the bundle path.
# instructions, the user skill root, and the role files, then prints the bundle
# path.
#
# Usage:
# bash dsh/install.sh --check
# bash dsh/install.sh --global
# bash dsh/install.sh --skills
# bash dsh/install.sh --roles
# bash dsh/install.sh --all
#
# Run it from a terminal, not from inside a DSH session: --global and --skills
# write outside the session workspace and would require approval.
# Run it from a terminal, not from inside a DSH session: --global, --skills and
# --roles write outside the session workspace and would require approval.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@ -22,15 +24,17 @@ DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
DO_CHECK=0
DO_GLOBAL=0
DO_SKILLS=0
DO_ROLES=0
usage() {
cat <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--all]
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all]
--check verify python3, git, node, hooks, bundle, and superpowers
--check verify python3, git, node, hooks, bundle, skills, and roles
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills symlink installed superpowers skills into \$DSH_HOME/skills
--all --check + --global + --skills, then print the bundle step
--skills copy installed superpowers skills into \$DSH_HOME/skills
--roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step
Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE
@ -47,7 +51,8 @@ for arg in "$@"; do
--check) DO_CHECK=1 ;;
--global) DO_GLOBAL=1 ;;
--skills) DO_SKILLS=1 ;;
--all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1 ;;
--roles) DO_ROLES=1 ;;
--all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1; DO_ROLES=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
esac
@ -97,17 +102,21 @@ check() {
echo "FAIL: hook bridge is missing (is dsh/bundle/hooks a symlink to ../../hooks?)" >&2
failed=1
fi
if superpowers_skills >/dev/null 2>&1; then
echo "ok: superpowers skills ($(superpowers_skills))"
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
fi
if [ -f "$HOME/.claude/roles/dev.md" ]; then
echo "ok: roles (~/.claude/roles)"
if [ -f "$DSH_HOME/roles/dev.md" ]; then
echo "ok: roles ($DSH_HOME/roles)"
elif [ -f "$HOME/.claude/roles/dev.md" ]; then
echo "ok: roles (~/.claude/roles; run --roles to copy)"
elif [ -f "$ROOT/roles/dev.md" ]; then
echo "ok: roles ($ROOT/roles; ~/.claude/roles is not installed)"
echo "ok: roles ($ROOT/roles; run --roles to install)"
else
echo "WARN: roles not found; /role will fail until ~/.claude/roles or $ROOT/roles exists" >&2
echo "WARN: roles not found; /role will fail until --roles is run" >&2
fi
echo "DSH_HOME=$DSH_HOME"
return "$failed"
@ -127,7 +136,8 @@ install_global() {
echo "Installed from $ROOT/global/CLAUDE.md by dsh/install.sh."
echo "The dsh-harness bundle enforces guard-main/require-plan and provides /role."
echo "DSH adapter: role hints come from the first message or /role; there is no SessionStart hint."
echo "DSH adapter: /role reads ~/.claude/roles when installed, otherwise $ROOT/roles."
echo "DSH adapter: /role reads ~/.dsh/roles, then ~/.claude/roles, then $ROOT/roles."
echo "DSH adapter: bootstrap prefers ~/.dsh/skills; the ~/.claude cache is a fallback."
echo "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore."
echo
cat "$ROOT/global/CLAUDE.md"
@ -139,18 +149,38 @@ install_skills() {
mkdir -p "$DSH_HOME/skills"
local skills
if ! skills="$(superpowers_skills)"; then
echo "WARN: superpowers skills not found; nothing linked" >&2
echo "WARN: superpowers skills not found; nothing copied" >&2
return 0
fi
local count=0
local dir name
local dir name dest
for dir in "$skills"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
ln -sfn "$dir" "$DSH_HOME/skills/$name"
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "linked $count superpowers skills into $DSH_HOME/skills"
echo "copied $count superpowers skills into $DSH_HOME/skills"
}
install_roles() {
mkdir -p "$DSH_HOME/roles"
local src="$ROOT/roles"
local count=0
local file name
for file in "$src"/*.md; do
[ -f "$file" ] || continue
name="$(basename "$file")"
cp "$file" "$DSH_HOME/roles/$name"
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "WARN: no role files found in $src" >&2
return 0
fi
echo "installed $count roles into $DSH_HOME/roles"
}
print_bundle_step() {
@ -177,7 +207,10 @@ fi
if [ "$DO_SKILLS" = 1 ]; then
install_skills
fi
if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ]; then
if [ "$DO_ROLES" = 1 ]; then
install_roles
fi
if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ] || [ "$DO_ROLES" = 1 ]; then
print_bundle_step
fi

View File

@ -88,3 +88,27 @@ test('resolveBootstrapSkill prefers an explicit path and finds the user root', (
cleanup(home)
}
})
test('resolveBootstrapSkill prefers the DSH copy over the Claude cache', () => {
const home = tempDir()
try {
const dsh = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
const claude = join(
home,
'.claude',
'plugins',
'cache',
'claude-plugins-official',
'superpowers',
'9.9.9',
'skills',
'using-superpowers',
'SKILL.md',
)
write(dsh, 'dsh copy\n')
write(claude, 'claude copy\n')
assert.equal(resolveBootstrapSkill({ home, env: {} }), dsh)
} finally {
cleanup(home)
}
})

View File

@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, lstatSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { cleanup, tempDir, write } from './helpers.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const script = join(here, '..', 'install.sh')
test('install.sh copies skills and roles instead of linking into ~/.claude', () => {
const home = tempDir()
try {
const dshHome = join(home, '.dsh')
const cache = join(
home,
'.claude',
'plugins',
'cache',
'claude-plugins-official',
'superpowers',
'7.0.0',
'skills',
)
write(join(cache, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n')
const env = { ...process.env, HOME: home, DSH_HOME: dshHome }
const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
const first = run()
assert.equal(first.status, 0, first.stderr)
assert.match(first.stdout, /copied 2 superpowers skills/)
assert.match(first.stdout, /installed 4 roles/)
const skill = join(dshHome, 'skills', 'using-superpowers')
assert.equal(lstatSync(skill).isSymbolicLink(), false)
assert.equal(lstatSync(skill).isDirectory(), true)
assert.match(readFileSync(join(skill, 'SKILL.md'), 'utf8'), /body/)
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
}
const second = run()
assert.equal(second.status, 0, second.stderr)
assert.equal(lstatSync(skill).isSymbolicLink(), false)
} finally {
cleanup(home)
}
})

View File

@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { join } from 'node:path'
import test from 'node:test'
import { resolveRolesDir } from '../bundle/index.js'
import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js'
import { cleanup, tempDir, write } from './helpers.mjs'
@ -74,6 +75,19 @@ test('buildRoleText fails loudly without the global layer', () => {
}
})
test('resolveRolesDir prefers ~/.dsh/roles over ~/.claude/roles', () => {
const home = tempDir()
try {
const dsh = join(home, '.dsh', 'roles')
const claude = join(home, '.claude', 'roles')
write(join(dsh, 'dev.md'), '# dsh\n')
write(join(claude, 'dev.md'), '# claude\n')
assert.equal(resolveRolesDir(join(home, 'bundle'), '', home), dsh)
} finally {
cleanup(home)
}
})
test('listRoles reports both layers', () => {
const root = tempDir()
try {