From 58d48b00692b3ca5291551bc989ce37e8a85da22 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 14:32:13 +0400 Subject: [PATCH] =?UTF-8?q?fix(dsh):=20follow-ups=20#49=20=E2=80=94=20lega?= =?UTF-8?q?cy-=D1=80=D0=BE=D0=BB=D0=B8=20=D1=82=D0=B5=D1=81=D1=82,=20pytho?= =?UTF-8?q?n3-guard,=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D1=8B=D1=85=20=D1=81=D0=BA=D0=B8=D0=BB=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2,=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=20O-1=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 31 ++++++++++++++++++- dsh/tests/install.test.mjs | 56 +++++++++++++++++++++++++++++++++++ dsh/tests/selfcheck.test.mjs | 29 ++++++++++++++++++ tests/test-hook-guard-main.sh | 1 + 4 files changed, 116 insertions(+), 1 deletion(-) 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 -- 2.47.2