DSH: установка без зависимости от ~/.claude (#49) #64

Merged
aep merged 7 commits from feat/49-dsh-install-source into main 2026-10-24 20:23:43 +03:00
7 changed files with 135 additions and 18 deletions
Showing only changes of commit 3bfcc583bb - Show all commits

View File

@ -81,6 +81,13 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read
- **Full installer**: `install.sh` prepares global instructions and skills; the
bundle itself is installed by `plugin_manager` because the Desktop profile is
Electron-managed.
- **Vendored superpowers**: the install-time source is explicit
(`--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or a clone in
`~/.dsh/superpowers`); a copy inside this repository is rejected because
upstream moves independently (change `dsh-install-source`, D-1). Removing the
legacy `~/.claude` fallbacks is deferred until every machine has rerun
`--skills --roles`; until then both runtime and installer warn when they use
one.
## 4. Architecture
@ -162,10 +169,10 @@ for the repository layout:
roleCommand: true
bootstrapSubagents: false
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/
rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/
projectRolesDir: '' # empty -> <cwd>/.claude/roles
roleNames: [dev, admin, analyst, qa]
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then legacy Claude Code paths
toolMappingPath: '' # empty -> bundle/dsh-tools.md
bootstrapMaxBytes: 65536
```
@ -200,7 +207,14 @@ with "requires ... in the Host scope".
`~/.dsh/skills`, `~/.agents/skills`, the newest
`~/.claude/plugins/cache/claude-plugins-official/superpowers/*/`, then
`~/.claude/skills`. The DSH copy wins because `dsh/install.sh --skills` copies
real files there; the Claude cache is only a fallback.
real files there; the Claude Code locations are a legacy fallback and the
bundle logs one warning per process when it resolves one of them.
Install-time source resolution is separate and stricter (change
`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` →
`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An
invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a
fresh install and a `WARN` when `~/.dsh/skills` already holds the skills.
### OpenSpec skills
@ -230,18 +244,21 @@ openspec archive <id> -y
## 6. Install and verify
Prerequisites: the `claude-harness` checkout, a superpowers source, DSH
0.2.1-rc.3, `python3`, `git`, Node. Claude Code itself is optional after
`dsh/install.sh --skills --roles` has copied the skills and roles into
`~/.dsh`; the hooks come from this checkout.
0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the
superpowers source once with
`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`,
then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this
checkout.
```bash
# 1. Static checks
bash dsh/install.sh --check
# 2. Global instructions, skills, and roles (run from a terminal; writes
# outside the session workspace)
# outside the session workspace). The superpowers source comes from
# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers.
bash dsh/install.sh --global
bash dsh/install.sh --skills
bash dsh/install.sh --skills # or: --skills --superpowers-from DIR
bash dsh/install.sh --roles
# or all three at once:
@ -344,10 +361,13 @@ profile was removed; the real `desktop` profile was not touched.
- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. The port uses two
live routes (`deepseek-flash`, `deepseek-v4-pro`) from the opt-in overlay in
`dsh/subagent-models.yml`; without it every dispatch runs on the session model.
- **`~/.claude` dependence.** `dsh/install.sh --skills --roles` copies skills
and roles into `~/.dsh`; the bootstrap and `/role` prefer those copies, and
the Claude cache/roles are fallbacks. An existing installation must rerun
`--skills --roles` once to become independent of a Claude Code rollback.
- **`~/.claude` dependence.** No longer needed for install, bootstrap, or
roles: the install-time source is explicit, and `--skills --roles` copy real
files into `~/.dsh`. The Claude Code locations remain a **legacy migration
fallback**; the installer, `--check`, and the runtime warn when they are used
(runtime — once per process). An existing installation must rerun
`--skills --roles` once to stop relying on them; removal of the fallbacks is
a separate task.
- **Superpowers porting rules.** The upstream guide requires a session-start
bootstrap with no per-session opt-in; `agent/pre-step` satisfies that shape,
but a real acceptance transcript is still required before claiming a port.
@ -374,3 +394,7 @@ profile was removed; the real `desktop` profile was not touched.
gets its own release cadence.
8. OpenSpec — **done** (change `dsh-openspec`): проектные скиллы, `--openspec`,
`--check`; приёмка — в дизайне change.
9. Install without `~/.claude` — **done** (change `dsh-install-source`):
`--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`/`~/.dsh/superpowers`, hard
`FAIL` instead of a silent empty bootstrap, DSH-owned role paths in the
installed `AGENTS.md`, legacy warnings at install and runtime.

View File

@ -140,10 +140,10 @@ repository layout:
roleCommand: true
bootstrapSubagents: false
hooksDir: '' # empty -> bundle/hooks -> ../../hooks
rolesDir: '' # empty -> ~/.dsh/roles, ~/.claude/roles, repo roles/
rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/
projectRolesDir: '' # empty -> <cwd>/.claude/roles
roleNames: [dev, admin, analyst, qa]
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then Claude cache
bootstrapSkillPath: '' # empty -> ~/.dsh/skills, then legacy Claude Code paths
toolMappingPath: '' # empty -> bundle/dsh-tools.md
bootstrapMaxBytes: 65536
```
@ -155,10 +155,14 @@ repository layout:
- `bundle/hooks` is a symlink for local-directory installation. A published
bundle needs a real copy of `hooks/` (already listed in `package.json`
`files`).
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`; the Claude
plugin cache is only a fallback, so a partial Claude Code rollback does not
break the bootstrap. Set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for
another layout.
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The
install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or
`~/.dsh/superpowers`; Claude Code locations are a **legacy migration
fallback** and log one warning per process when used, so a partial Claude
Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or
`DSH_SUPERPOWERS_SKILL` for another layout.
- Roles resolve `~/.dsh/roles` first; `~/.claude/roles` and the repository
`roles/` are fallbacks. After `--roles` the runtime never needs `~/.claude`.
- Role loading is model-driven through the `role` skill; there is no automatic
`agent/pre-step` injection. If the model skips the skill, use the `/role`
command or ask it to load the role explicitly.

View File

@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url'
import { registerRoleCommand } from './command-role.js'
import { createGuard } from './lib/guards.js'
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
import { createUserMessage } from './lib/message.js'
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
@ -93,6 +94,9 @@ export function apply(ctx, rawConfig = {}) {
const bundleDir = dirname(fileURLToPath(import.meta.url))
const hooksDir = resolveHooksDir(bundleDir, config.hooksDir)
const rolesDir = resolveRolesDir(bundleDir, config.rolesDir)
if (isLegacyClaudePath(rolesDir)) {
warnLegacyOnce(ctx.logger, 'roles', rolesDir, 'roles')
}
const guard = createGuard({ hooksDir, logger: ctx.logger })
if (config.enabled && config.guards) {

View File

@ -12,6 +12,7 @@ import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { isLegacyClaudePath, warnLegacyOnce } from './legacy.js'
const DEFAULT_MAPPING = [
'Use the tools reported by the harness. In particular:',
@ -141,6 +142,9 @@ export function prepareBootstrap({
logger?.warn?.('[dsh-harness] superpowers bootstrap skill not found; skipping injection')
return undefined
}
if (isLegacyClaudePath(resolved, home)) {
warnLegacyOnce(logger, 'bootstrap', resolved, 'skills')
}
const mappingFile = mappingPath && existsSync(mappingPath) ? mappingPath : ''
const key = `${resolved}|${mappingFile}|${maxBytes}`

35
dsh/bundle/lib/legacy.js Normal file
View File

@ -0,0 +1,35 @@
/**
* Legacy Claude Code source detection.
*
* After #37/#38 the DSH runtime prefers `~/.dsh` for skills and roles. The
* `~/.claude` locations stay as a migration fallback: a machine that has not
* rerun `dsh/install.sh --skills --roles` keeps working, but the agent should
* say so once instead of serving legacy paths silently.
*/
import { homedir } from 'node:os'
import { join, sep } from 'node:path'
const warned = new Set()
/** Whether `path` lives under `<home>/.claude` (the legacy source root). */
export function isLegacyClaudePath(path, home = homedir()) {
if (typeof path !== 'string' || path === '') return false
return path.startsWith(join(home, '.claude') + sep)
}
/**
* Warn once per process and per kind+path: `agent/pre-step` runs on every
* step, so an unconditional warning would flood the log.
*/
export function warnLegacyOnce(logger, kind, path, flag = kind) {
const key = `${kind}|${path}`
if (warned.has(key)) return
warned.add(key)
logger?.warn?.(
'[dsh-harness] legacy Claude Code source in use for %s: %s; run dsh/install.sh --%s',
kind,
path,
flag,
)
}

View File

@ -112,3 +112,36 @@ test('resolveBootstrapSkill prefers the DSH copy over the Claude cache', () => {
cleanup(home)
}
})
test('prepareBootstrap warns once when the legacy ~/.claude skill is used', () => {
const home = tempDir()
try {
const legacy = join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md')
write(legacy, '---\nname: using-superpowers\n---\nlegacy body\n')
const warnings = []
const logger = { warn: (...args) => warnings.push(args) }
const first = prepareBootstrap({ home, env: {}, logger })
const second = prepareBootstrap({ home, env: {}, logger })
assert.equal(first.path, legacy)
assert.ok(second)
assert.equal(warnings.length, 1, 'one warning per process, not per step')
assert.match(String(warnings[0][0]), /legacy Claude Code source/)
assert.deepEqual(warnings[0].slice(-3), ['bootstrap', legacy, 'skills'])
} finally {
cleanup(home)
}
})
test('prepareBootstrap stays silent for the DSH-owned copy', () => {
const home = tempDir()
try {
write(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), '---\nname: x\n---\nbody\n')
const warnings = []
prepareBootstrap({ home, env: {}, logger: { warn: (...args) => warnings.push(args) } })
assert.equal(warnings.length, 0)
} finally {
cleanup(home)
}
})

View File

@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { join } from 'node:path'
import test from 'node:test'
import { resolveRolesDir } from '../bundle/index.js'
import { isLegacyClaudePath } from '../bundle/lib/legacy.js'
import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js'
import { cleanup, tempDir, write } from './helpers.mjs'
@ -108,3 +109,15 @@ test('listRoles reports both layers', () => {
cleanup(root)
}
})
test('isLegacyClaudePath detects ~/.claude paths only', () => {
const home = tempDir()
try {
assert.equal(isLegacyClaudePath(join(home, '.claude', 'roles', 'dev.md'), home), true)
assert.equal(isLegacyClaudePath(join(home, '.dsh', 'roles', 'dev.md'), home), false)
assert.equal(isLegacyClaudePath(join(home, '.claude-other', 'x'), home), false)
assert.equal(isLegacyClaudePath('', home), false)
} finally {
cleanup(home)
}
})