From 3bfcc583bb2a2861f1310cd37fe8e2fa2ddd5088 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 21:12:21 +0400 Subject: [PATCH] feat(dsh): warn about legacy ~/.claude sources at runtime (#49) --- dsh/PORT.md | 48 +++++++++++++++++++++++++++--------- dsh/README.md | 16 +++++++----- dsh/bundle/index.js | 4 +++ dsh/bundle/lib/bootstrap.js | 4 +++ dsh/bundle/lib/legacy.js | 35 ++++++++++++++++++++++++++ dsh/tests/bootstrap.test.mjs | 33 +++++++++++++++++++++++++ dsh/tests/role.test.mjs | 13 ++++++++++ 7 files changed, 135 insertions(+), 18 deletions(-) create mode 100644 dsh/bundle/lib/legacy.js diff --git a/dsh/PORT.md b/dsh/PORT.md index db4d5f6..858f3b9 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -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 -> /.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 -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. diff --git a/dsh/README.md b/dsh/README.md index 934d84f..3fa3445 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -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 -> /.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. diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 244edc8..4d91a6b 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -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) { diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js index bb0c84b..99b49f4 100644 --- a/dsh/bundle/lib/bootstrap.js +++ b/dsh/bundle/lib/bootstrap.js @@ -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}` diff --git a/dsh/bundle/lib/legacy.js b/dsh/bundle/lib/legacy.js new file mode 100644 index 0000000..1c82202 --- /dev/null +++ b/dsh/bundle/lib/legacy.js @@ -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 `/.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, + ) +} diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs index 4764c6d..1af85ce 100644 --- a/dsh/tests/bootstrap.test.mjs +++ b/dsh/tests/bootstrap.test.mjs @@ -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) + } +}) diff --git a/dsh/tests/role.test.mjs b/dsh/tests/role.test.mjs index 79f705a..88408d1 100644 --- a/dsh/tests/role.test.mjs +++ b/dsh/tests/role.test.mjs @@ -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) + } +})