diff --git a/dsh/install.sh b/dsh/install.sh
index f9f9cf8..ee7ad56 100755
--- a/dsh/install.sh
+++ b/dsh/install.sh
@@ -372,6 +372,10 @@ check() {
}
install_global() {
+ if ! command -v python3 >/dev/null 2>&1; then
+ echo "FAIL: python3 is required for --global (AGENTS.md is generated from global/CLAUDE.md)" >&2
+ return 1
+ fi
warn_mixed_dsh_home
mkdir -p "$DSH_HOME"
local target="$DSH_HOME/AGENTS.md"
@@ -494,16 +498,41 @@ sync_skills() {
# Copy every
/SKILL.md under $1 into $DSH_HOME/skills (rm -rf dest, cp -R).
# The optional label names where the skills came from in the report line.
+# $DSH_HOME/.superpowers-manifest remembers the installed superpowers set (one
+# name per line, sorted): a skill present in the previous manifest but missing
+# from the new set is removed from $DSH_HOME/skills. Local skills (role, sdd)
+# are installed by install_local_skills after this and never enter the manifest.
copy_skills_from() {
- local src="$1" label="${2:-$1}" count=0 dir name dest
+ local src="$1" label="${2:-$1}" count=0 dir name dest line
+ local manifest="$DSH_HOME/.superpowers-manifest"
+ local old=() new=()
+ if [ -f "$manifest" ]; then
+ while IFS= read -r line; do [ -n "$line" ] && old+=("$line"); done < "$manifest"
+ fi
for dir in "$src"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
+ new+=("$name")
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
+ local keep found
+ for name in ${old[@]+"${old[@]}"}; do
+ found=0
+ for keep in ${new[@]+"${new[@]}"}; do
+ if [ "$keep" = "$name" ]; then
+ found=1
+ break
+ fi
+ done
+ if [ "$found" -eq 0 ]; then
+ rm -rf "$DSH_HOME/skills/$name"
+ echo "removed $DSH_HOME/skills/$name (no longer in the superpowers source)"
+ fi
+ done
+ printf '%s\n' ${new[@]+"${new[@]}"} | sort -u > "$manifest"
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
}
diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs
index d43c708..24ddaed 100644
--- a/dsh/tests/install.test.mjs
+++ b/dsh/tests/install.test.mjs
@@ -456,6 +456,44 @@ test('--skills syncs an explicit --superpowers-from source in a clean HOME', ()
}
})
+test('--skills removes a skill that disappeared upstream (M-3)', () => {
+ const work = tempDir()
+ try {
+ const home = join(work, 'home')
+ const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
+ const manifest = join(home, '.dsh', '.superpowers-manifest')
+ const installed = join(home, '.dsh', 'skills')
+
+ const first = runInstaller(home, ['--skills', '--superpowers-from', src])
+ assert.equal(first.status, 0, first.stdout + first.stderr)
+ assert.match(first.stdout, /copied 15 superpowers skills/)
+ assert.ok(existsSync(manifest), 'the superpowers manifest is written by --skills')
+ assert.match(readFileSync(manifest, 'utf8'), /using-superpowers/)
+
+ // Simulate a skill the upstream no longer ships: it stays on disk and in
+ // the old manifest, but the new set does not contain it. The sync itself
+ // rejects a changed 15-skill upstream set, so the manifest is the only
+ // place the install remembers the previous set.
+ write(join(installed, 'extra-skill', 'SKILL.md'), '---\nname: extra-skill\n---\nbody\n')
+ write(manifest, `${readFileSync(manifest, 'utf8')}extra-skill\n`)
+
+ const second = runInstaller(home, ['--skills', '--superpowers-from', src])
+ assert.equal(second.status, 0, second.stdout + second.stderr)
+ assert.match(second.stdout, /copied 15 superpowers skills/)
+ assert.ok(
+ second.stdout.includes(`removed ${join(installed, 'extra-skill')} (no longer in the superpowers source)`),
+ second.stdout,
+ )
+ assert.equal(existsSync(join(installed, 'extra-skill')), false, 'the stale skill directory is removed')
+ assert.ok(existsSync(join(installed, 'using-superpowers', 'SKILL.md')))
+ assert.ok(existsSync(join(installed, 'brainstorming', 'SKILL.md')))
+ assert.ok(existsSync(join(installed, 'role', 'SKILL.md')), 'local skills are not touched')
+ assert.doesNotMatch(readFileSync(manifest, 'utf8'), /extra-skill/, 'the manifest reflects the new set')
+ } finally {
+ cleanup(work)
+ }
+})
+
test('--superpowers-from accepts the skills directory itself', () => {
const home = tempDir()
try {
@@ -641,6 +679,24 @@ test('--global installs DSH role paths and explains the instruction format', ()
}
})
+test('--global fails without python3 and writes no truncated AGENTS.md (M-2)', () => {
+ const home = tempDir()
+ try {
+ const emptyBin = join(home, 'empty-bin')
+ mkdirSync(emptyBin, { recursive: true })
+ const run = spawnSync('/bin/bash', [script, '--global'], {
+ env: { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: emptyBin },
+ encoding: 'utf8',
+ })
+ const out = run.stdout + run.stderr
+ assert.equal(run.status, 1, out)
+ assert.match(out, /python3 is required/)
+ assert.equal(existsSync(join(home, '.dsh', 'AGENTS.md')), false)
+ } finally {
+ cleanup(home)
+ }
+})
+
test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => {
const work = tempDir()
try {
diff --git a/dsh/tests/selfcheck.test.mjs b/dsh/tests/selfcheck.test.mjs
index 093fb16..b940d55 100644
--- a/dsh/tests/selfcheck.test.mjs
+++ b/dsh/tests/selfcheck.test.mjs
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
+import { homedir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
@@ -135,6 +136,34 @@ function recordingLogger(errors) {
}
}
+test('apply warns once when roles resolve to a legacy Claude Code path (M-1)', () => {
+ const warnings = []
+ const ctx = {
+ tools: { guard() {} },
+ on() {},
+ inject() {},
+ workingDirectory: { get() {} },
+ logger: {
+ info() {},
+ error() {},
+ warn(fmt, ...args) {
+ warnings.push(fmt.replace(/%s/g, () => String(args.shift() ?? '')))
+ },
+ },
+ }
+
+ apply(ctx, { rolesDir: join(homedir(), '.claude', 'roles') })
+
+ assert.ok(
+ warnings.some(
+ (line) =>
+ line.includes('legacy Claude Code source in use for roles') &&
+ line.includes(join(homedir(), '.claude', 'roles')),
+ ),
+ `no legacy-roles warning among: ${warnings.join(' | ')}`,
+ )
+})
+
test('apply does not throw when ctx.on and ctx.inject are missing', () => {
const calls = { guard: 0 }
const errors = []
diff --git a/tests/test-hook-guard-main.sh b/tests/test-hook-guard-main.sh
index 85bc647..c8acfaa 100644
--- a/tests/test-hook-guard-main.sh
+++ b/tests/test-hook-guard-main.sh
@@ -26,6 +26,7 @@ assert_eq "вложенный worktree: git commit в нём" "$(rc "$W" Bash 'g
assert_eq "вложенный worktree: запись в основной checkout на main" "$(rc "$W" Bash "echo x > $S/n/a.md")" 2
assert_eq "вложенный worktree: Write в основной checkout рядом" "$(rc "$W" Write "$S/n/.claude/x.md")" 2
assert_eq "git worktree add .claude/worktrees/… на main" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/y -b feat/y origin/main')" 0
+assert_eq "worktree add -b chore/x без start-point (O-1 #66)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/o1 -b chore/x')" 0
# цепочка создания worktree и записи в нём одной командой (#63, O-4): цель под новым путём — по новой ветке
assert_eq "worktree add -b && cd && openspec archive (#63)" "$(rc "$S/n" Bash 'git worktree add .claude/worktrees/z -b feat/z origin/main && cd .claude/worktrees/z && openspec archive y -y')" 0
assert_eq "worktree add -C && cd && openspec archive (#63)" "$(rc "$S/ws" Bash "git -C $S/n worktree add .claude/worktrees/q -b feat/q origin/main && cd $S/n/.claude/worktrees/q && openspec archive y -y")" 0