feat(dsh): отвязать skills и roles от ~/.claude (#37)
This commit is contained in:
parent
f422119090
commit
306ec2e174
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
|
`bundle/dsh-tools.md`, wraps the result in `<EXTREMELY_IMPORTANT>`, and
|
||||||
injects it once per session. A content-hash marker handles dedupe; scanning
|
injects it once per session. A content-hash marker handles dedupe; scanning
|
||||||
`session.surface.nodes` re-injects after compaction.
|
`session.surface.nodes` re-injects after compaction.
|
||||||
3. **`/role`** as a real command (`ctx.commands.register`). It reads the global
|
3. **`/role`** as a real command (`ctx.commands.register`). It reads
|
||||||
`~/.claude/roles/<role>.md` and the project `.claude/roles/<role>.md`, injects
|
`~/.dsh/roles/<role>.md`, falls back to `~/.claude/roles/<role>.md`, then to
|
||||||
the role text as a user message, and starts a turn for the five-line summary.
|
the repository's `roles/`, and reads the project `.claude/roles/<role>.md`.
|
||||||
Auto-memory is explicitly dropped; the role's "Читать на старте" section is
|
It injects the role text as a user message and starts a turn for the
|
||||||
left to the model.
|
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
|
4. **Display metadata and an optional CodeGraph MCP overlay** so the bundle can
|
||||||
be installed through the Plugins page and codegraph stays available.
|
be installed through the Plugins page and codegraph stays available.
|
||||||
|
|
||||||
@ -149,31 +150,39 @@ for the repository layout:
|
|||||||
roleCommand: true
|
roleCommand: true
|
||||||
bootstrapSubagents: false
|
bootstrapSubagents: false
|
||||||
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
|
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
|
projectRolesDir: '' # empty -> <cwd>/.claude/roles
|
||||||
roleNames: [dev, admin, analyst, qa]
|
roleNames: [dev, admin, analyst, qa]
|
||||||
bootstrapSkillPath: '' # empty -> newest superpowers plugin cache
|
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
|
||||||
toolMappingPath: '' # empty -> bundle/dsh-tools.md
|
toolMappingPath: '' # empty -> bundle/dsh-tools.md
|
||||||
bootstrapMaxBytes: 65536
|
bootstrapMaxBytes: 65536
|
||||||
```
|
```
|
||||||
|
|
||||||
`bootstrapSkillPath` resolution order: explicit path, `DSH_SUPERPOWERS_SKILL`,
|
`bootstrapSkillPath` resolution order: explicit path, `DSH_SUPERPOWERS_SKILL`,
|
||||||
the newest `~/.claude/plugins/cache/claude-plugins-official/superpowers/*/`,
|
`~/.dsh/skills`, `~/.agents/skills`, the newest
|
||||||
then `~/.claude/skills`, `~/.dsh/skills`, and `~/.agents/skills`.
|
`~/.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
|
## 6. Install and verify
|
||||||
|
|
||||||
Prerequisites: `claude-harness` installed in `~/.claude` (hooks, roles,
|
Prerequisites: the `claude-harness` checkout, a superpowers source, DSH
|
||||||
skills), superpowers 6.4.x, DSH 0.2.1-rc.3, `python3`, `git`, Node.
|
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
|
```bash
|
||||||
# 1. Static checks
|
# 1. Static checks
|
||||||
bash dsh/install.sh --check
|
bash dsh/install.sh --check
|
||||||
|
|
||||||
# 2. Global instructions and skills (run from a terminal; writes outside the
|
# 2. Global instructions, skills, and roles (run from a terminal; writes
|
||||||
# session workspace)
|
# outside the session workspace)
|
||||||
bash dsh/install.sh --global
|
bash dsh/install.sh --global
|
||||||
bash dsh/install.sh --skills
|
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:
|
# 3. Install the bundle in DSH:
|
||||||
# Plugins -> install bundle from dsh/bundle
|
# 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.
|
`<SUBAGENT-STOP>` makes that harmless but token-expensive.
|
||||||
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. Map them to live
|
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. Map them to live
|
||||||
provider/model routes before porting the SDD text.
|
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
|
- **Superpowers porting rules.** The upstream guide requires a session-start
|
||||||
bootstrap with no per-session opt-in; `agent/pre-step` satisfies that shape,
|
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.
|
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
|
# Static check
|
||||||
bash dsh/install.sh --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)
|
# (run from a terminal; these write outside the session workspace)
|
||||||
bash dsh/install.sh --global
|
bash dsh/install.sh --global
|
||||||
bash dsh/install.sh --skills
|
bash dsh/install.sh --skills
|
||||||
|
bash dsh/install.sh --roles
|
||||||
|
|
||||||
# Install the bundle in DSH:
|
# Install the bundle in DSH:
|
||||||
# Plugins -> install a bundle from dsh/bundle
|
# Plugins -> install a bundle from dsh/bundle
|
||||||
@ -104,10 +105,10 @@ repository layout:
|
|||||||
roleCommand: true
|
roleCommand: true
|
||||||
bootstrapSubagents: false
|
bootstrapSubagents: false
|
||||||
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
|
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
|
projectRolesDir: '' # empty -> <cwd>/.claude/roles
|
||||||
roleNames: [dev, admin, analyst, qa]
|
roleNames: [dev, admin, analyst, qa]
|
||||||
bootstrapSkillPath: '' # empty -> newest superpowers plugin cache
|
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
|
||||||
toolMappingPath: '' # empty -> bundle/dsh-tools.md
|
toolMappingPath: '' # empty -> bundle/dsh-tools.md
|
||||||
bootstrapMaxBytes: 65536
|
bootstrapMaxBytes: 65536
|
||||||
```
|
```
|
||||||
@ -119,8 +120,10 @@ repository layout:
|
|||||||
- `bundle/hooks` is a symlink for local-directory installation. A published
|
- `bundle/hooks` is a symlink for local-directory installation. A published
|
||||||
bundle needs a real copy of `hooks/` (already listed in `package.json`
|
bundle needs a real copy of `hooks/` (already listed in `package.json`
|
||||||
`files`).
|
`files`).
|
||||||
- `using-superpowers` content is read from the installed Claude plugin cache;
|
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`; the Claude
|
||||||
set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for another layout.
|
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
|
- Model names in roles and superpowers (`sonnet`, `haiku`, `opus`) are not DSH
|
||||||
models. Map them to live provider/model routes before porting SDD.
|
models. Map them to live provider/model routes before porting SDD.
|
||||||
- SDD is intentionally out of scope for this spike; see
|
- SDD is intentionally out of scope for this spike; see
|
||||||
|
|||||||
@ -38,14 +38,16 @@ function resolveHooksDir(bundleDir, configured) {
|
|||||||
return join(bundleDir, '..', '..', 'hooks')
|
return join(bundleDir, '..', '..', 'hooks')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefer the installed global roles, then the repository's source roles. */
|
/** Prefer DSH-installed roles, then Claude Code, then the repository source. */
|
||||||
function resolveRolesDir(bundleDir, configured) {
|
export function resolveRolesDir(bundleDir, configured, home = homedir()) {
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
const installed = join(homedir(), '.claude', 'roles')
|
const dshInstalled = join(home, '.dsh', 'roles')
|
||||||
if (existsSync(join(installed, 'dev.md'))) return installed
|
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')
|
const source = join(bundleDir, '..', '..', 'roles')
|
||||||
if (existsSync(join(source, 'dev.md'))) return source
|
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 = []
|
const out = []
|
||||||
if (env.DSH_SUPERPOWERS_SKILL) out.push(env.DSH_SUPERPOWERS_SKILL)
|
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')
|
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers')
|
||||||
if (existsSync(root)) {
|
if (existsSync(root)) {
|
||||||
let entries = []
|
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, '.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
|
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() } = {}) {
|
export function resolveBootstrapSkill({ configuredPath = '', env = process.env, home = homedir() } = {}) {
|
||||||
if (configuredPath && existsSync(configuredPath)) return configuredPath
|
if (configuredPath && existsSync(configuredPath)) return configuredPath
|
||||||
return superpowersCandidates({ home, env }).find((path) => existsSync(path))
|
return superpowersCandidates({ home, env }).find((path) => existsSync(path))
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
* Role loading for the `/role` command.
|
* Role loading for the `/role` command.
|
||||||
*
|
*
|
||||||
* The file format stays the claude-harness format: a global layer
|
* The file format stays the claude-harness format: a global layer
|
||||||
* (`~/.claude/roles/<role>.md`) and an optional project layer
|
* (`~/.dsh/roles/<role>.md`, falling back to `~/.claude/roles`), and an optional
|
||||||
* (`<workspace>/.claude/roles/<role>.md`). The project layer wins on conflict.
|
* project layer (`<workspace>/.claude/roles/<role>.md`). The project layer wins
|
||||||
* Auto-memory is Claude Code-only; this port explicitly drops that step and
|
* on conflict. Auto-memory is Claude Code-only; this port explicitly drops that
|
||||||
* leaves the role's "Читать на старте" section to the model.
|
* step and leaves the role's "Читать на старте" section to the model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
@ -13,6 +13,7 @@ import { homedir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
export const DEFAULT_ROLE_NAMES = ['dev', 'admin', 'analyst', 'qa']
|
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. */
|
/** Parse `/role [name]` without owning any grammar beyond the first word. */
|
||||||
export function parseRole(rawInput, roleNames = DEFAULT_ROLE_NAMES) {
|
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. */
|
/** 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 globalPath = join(rolesDir, `${role}.md`)
|
||||||
const projectPath = join(projectRolesDir || join(cwd, '.claude', 'roles'), `${role}.md`)
|
const projectPath = join(projectRolesDir || join(cwd, '.claude', 'roles'), `${role}.md`)
|
||||||
return { globalPath, projectPath }
|
return { globalPath, projectPath }
|
||||||
@ -41,7 +42,7 @@ function readIfPresent(path) {
|
|||||||
/** Render the list shown by a bare `/role`. */
|
/** Render the list shown by a bare `/role`. */
|
||||||
export function listRoles({ cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) {
|
export function listRoles({ cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) {
|
||||||
const lines = [
|
const lines = [
|
||||||
`Роли claude-harness; общий слой: ${rolesDir || join(homedir(), '.claude', 'roles')}`,
|
`Роли claude-harness; общий слой: ${rolesDir || DEFAULT_ROLES_DIR}`,
|
||||||
`Workspace: ${cwd}`,
|
`Workspace: ${cwd}`,
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,16 +4,18 @@
|
|||||||
# This script never installs the Cordis bundle into a profile: the Desktop
|
# 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
|
# 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
|
# 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:
|
# Usage:
|
||||||
# bash dsh/install.sh --check
|
# bash dsh/install.sh --check
|
||||||
# bash dsh/install.sh --global
|
# bash dsh/install.sh --global
|
||||||
# bash dsh/install.sh --skills
|
# bash dsh/install.sh --skills
|
||||||
|
# bash dsh/install.sh --roles
|
||||||
# bash dsh/install.sh --all
|
# bash dsh/install.sh --all
|
||||||
#
|
#
|
||||||
# Run it from a terminal, not from inside a DSH session: --global and --skills
|
# Run it from a terminal, not from inside a DSH session: --global, --skills and
|
||||||
# write outside the session workspace and would require approval.
|
# --roles write outside the session workspace and would require approval.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@ -22,15 +24,17 @@ DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
|||||||
DO_CHECK=0
|
DO_CHECK=0
|
||||||
DO_GLOBAL=0
|
DO_GLOBAL=0
|
||||||
DO_SKILLS=0
|
DO_SKILLS=0
|
||||||
|
DO_ROLES=0
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
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
|
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
|
||||||
--skills symlink installed superpowers skills into \$DSH_HOME/skills
|
--skills copy installed superpowers skills into \$DSH_HOME/skills
|
||||||
--all --check + --global + --skills, then print the bundle step
|
--roles copy roles/ into \$DSH_HOME/roles
|
||||||
|
--all --check + --global + --skills + --roles, then print the bundle step
|
||||||
|
|
||||||
Bundle installation itself is done in DSH:
|
Bundle installation itself is done in DSH:
|
||||||
Plugins -> install a bundle from $BUNDLE
|
Plugins -> install a bundle from $BUNDLE
|
||||||
@ -47,7 +51,8 @@ for arg in "$@"; do
|
|||||||
--check) DO_CHECK=1 ;;
|
--check) DO_CHECK=1 ;;
|
||||||
--global) DO_GLOBAL=1 ;;
|
--global) DO_GLOBAL=1 ;;
|
||||||
--skills) DO_SKILLS=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 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
|
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@ -97,17 +102,21 @@ check() {
|
|||||||
echo "FAIL: hook bridge is missing (is dsh/bundle/hooks a symlink to ../../hooks?)" >&2
|
echo "FAIL: hook bridge is missing (is dsh/bundle/hooks a symlink to ../../hooks?)" >&2
|
||||||
failed=1
|
failed=1
|
||||||
fi
|
fi
|
||||||
if superpowers_skills >/dev/null 2>&1; then
|
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
|
||||||
echo "ok: superpowers skills ($(superpowers_skills))"
|
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
|
else
|
||||||
echo "WARN: superpowers plugin not found; the bootstrap will be skipped" >&2
|
echo "WARN: superpowers plugin not found; the bootstrap will be skipped" >&2
|
||||||
fi
|
fi
|
||||||
if [ -f "$HOME/.claude/roles/dev.md" ]; then
|
if [ -f "$DSH_HOME/roles/dev.md" ]; then
|
||||||
echo "ok: roles (~/.claude/roles)"
|
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
|
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
|
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
|
fi
|
||||||
echo "DSH_HOME=$DSH_HOME"
|
echo "DSH_HOME=$DSH_HOME"
|
||||||
return "$failed"
|
return "$failed"
|
||||||
@ -127,7 +136,8 @@ install_global() {
|
|||||||
echo "Installed from $ROOT/global/CLAUDE.md by dsh/install.sh."
|
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 "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 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 "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore."
|
||||||
echo
|
echo
|
||||||
cat "$ROOT/global/CLAUDE.md"
|
cat "$ROOT/global/CLAUDE.md"
|
||||||
@ -139,18 +149,38 @@ install_skills() {
|
|||||||
mkdir -p "$DSH_HOME/skills"
|
mkdir -p "$DSH_HOME/skills"
|
||||||
local skills
|
local skills
|
||||||
if ! skills="$(superpowers_skills)"; then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
local count=0
|
local count=0
|
||||||
local dir name
|
local dir name dest
|
||||||
for dir in "$skills"/*; do
|
for dir in "$skills"/*; do
|
||||||
[ -f "$dir/SKILL.md" ] || continue
|
[ -f "$dir/SKILL.md" ] || continue
|
||||||
name="$(basename "$dir")"
|
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))
|
count=$((count + 1))
|
||||||
done
|
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() {
|
print_bundle_step() {
|
||||||
@ -177,7 +207,10 @@ fi
|
|||||||
if [ "$DO_SKILLS" = 1 ]; then
|
if [ "$DO_SKILLS" = 1 ]; then
|
||||||
install_skills
|
install_skills
|
||||||
fi
|
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
|
print_bundle_step
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -88,3 +88,27 @@ test('resolveBootstrapSkill prefers an explicit path and finds the user root', (
|
|||||||
cleanup(home)
|
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 assert from 'node:assert/strict'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { resolveRolesDir } from '../bundle/index.js'
|
||||||
import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js'
|
import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js'
|
||||||
import { cleanup, tempDir, write } from './helpers.mjs'
|
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', () => {
|
test('listRoles reports both layers', () => {
|
||||||
const root = tempDir()
|
const root = tempDir()
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user