fix(dsh): follow-ups #49 — legacy-роли тест, python3-guard, очистка старых скиллов, регрессия O-1 (#66)

This commit is contained in:
Andrey Epifancev 2026-10-25 14:32:13 +04:00
parent 9074eff33e
commit 58d48b0069
4 changed files with 116 additions and 1 deletions

View File

@ -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 <dir>/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)"
}

View File

@ -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 {

View File

@ -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 = []

View File

@ -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