DSH: отвязать skills и roles от ~/.claude (#37) #38
62
docs/superpowers/plans/2026-10-24-dsh-decouple.md
Normal file
62
docs/superpowers/plans/2026-10-24-dsh-decouple.md
Normal 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.
|
||||
39
dsh/PORT.md
39
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 `<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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
10
dsh/bundle/lib/bootstrap.js
vendored
10
dsh/bundle/lib/bootstrap.js
vendored
@ -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))
|
||||
|
||||
@ -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}`,
|
||||
'',
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
52
dsh/tests/install.test.mjs
Normal file
52
dsh/tests/install.test.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user