From 306ec2e1742596e0b65d70329b4bc3959b3d4db9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 16:10:39 +0400 Subject: [PATCH] =?UTF-8?q?feat(dsh):=20=D0=BE=D1=82=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20skills=20=D0=B8=20roles=20=D0=BE=D1=82=20~?= =?UTF-8?q?/.claude=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-24-dsh-decouple.md | 62 ++++++++++++++++ dsh/PORT.md | 39 ++++++---- dsh/README.md | 13 ++-- dsh/bundle/index.js | 12 +-- dsh/bundle/lib/bootstrap.js | 10 ++- dsh/bundle/lib/role.js | 13 ++-- dsh/install.sh | 73 ++++++++++++++----- dsh/tests/bootstrap.test.mjs | 24 ++++++ dsh/tests/install.test.mjs | 52 +++++++++++++ dsh/tests/role.test.mjs | 14 ++++ 10 files changed, 260 insertions(+), 52 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-24-dsh-decouple.md create mode 100644 dsh/tests/install.test.mjs diff --git a/docs/superpowers/plans/2026-10-24-dsh-decouple.md b/docs/superpowers/plans/2026-10-24-dsh-decouple.md new file mode 100644 index 0000000..57ef92c --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-dsh-decouple.md @@ -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 + +- Проектные роли `/.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. diff --git a/dsh/PORT.md b/dsh/PORT.md index ec2dab5..5f950c0 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -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 ``, 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/.md` and the project `.claude/roles/.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/.md`, falls back to `~/.claude/roles/.md`, then to + the repository's `roles/`, and reads the project `.claude/roles/.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 -> /.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. `` 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. diff --git a/dsh/README.md b/dsh/README.md index 98d219b..571382a 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -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 -> /.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 diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index edbc028..244edc8 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -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 } diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js index d4ace29..bb0c84b 100644 --- a/dsh/bundle/lib/bootstrap.js +++ b/dsh/bundle/lib/bootstrap.js @@ -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)) diff --git a/dsh/bundle/lib/role.js b/dsh/bundle/lib/role.js index 2417a1d..b7b090c 100644 --- a/dsh/bundle/lib/role.js +++ b/dsh/bundle/lib/role.js @@ -2,10 +2,10 @@ * Role loading for the `/role` command. * * The file format stays the claude-harness format: a global layer - * (`~/.claude/roles/.md`) and an optional project layer - * (`/.claude/roles/.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/.md`, falling back to `~/.claude/roles`), and an optional + * project layer (`/.claude/roles/.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}`, '', ] diff --git a/dsh/install.sh b/dsh/install.sh index 0e88cd5..2aff5e6 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -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 < 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 diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs index bd2ddd6..4764c6d 100644 --- a/dsh/tests/bootstrap.test.mjs +++ b/dsh/tests/bootstrap.test.mjs @@ -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) + } +}) diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs new file mode 100644 index 0000000..4b62f66 --- /dev/null +++ b/dsh/tests/install.test.mjs @@ -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) + } +}) diff --git a/dsh/tests/role.test.mjs b/dsh/tests/role.test.mjs index c635b0d..79f705a 100644 --- a/dsh/tests/role.test.mjs +++ b/dsh/tests/role.test.mjs @@ -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 {