claude-harness/dsh/install.sh

697 lines
26 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Install the DeepSeek Harness side of claude-harness.
#
# This script never installs the Cordis bundle into a profile: the Desktop
# profile is Electron-managed, so the bundle goes through the GUI Plugins page
# or the model-facing `plugin_manager` tool. The script prepares the global
# instructions, the user skill root, and the role files, then prints the bundle
# path.
#
# Usage:
# bash dsh/install.sh --check
# bash dsh/install.sh --global
# bash dsh/install.sh --skills [--superpowers-from <dir>]
# bash dsh/install.sh --roles
# bash dsh/install.sh --all
# bash dsh/install.sh --openspec
#
# Run it from a terminal, not from inside a DSH session: --global, --skills and
# --roles write outside the session workspace and would require approval.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUNDLE="$ROOT/dsh/bundle"
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}"
DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
DO_CHECK=0
DO_GLOBAL=0
DO_SKILLS=0
DO_ROLES=0
DO_OPENSPEC=0
SUPERPOWERS_FROM=""
SUPERPOWERS_FROM_SET=0
CLONE_HINT="git clone --depth 1 --branch $DSH_SUPERPOWERS_REF $DSH_SUPERPOWERS_REPO $DSH_HOME/superpowers"
# Synced staging trees, per source kind: $DSH_HOME/superpowers is reserved for
# the pinned clone (T3.1), so explicit sources stage beside it.
PINNED_OUT="$DSH_HOME/superpowers/skills.synced"
EXPLICIT_OUT="$DSH_HOME/.skills.synced"
usage() {
cat <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
bash dsh/install.sh --skills --superpowers-from <dir>
--check verify python3, git, node, hooks, bundle, skills, and roles (offline)
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills sync + verify + copy superpowers and local dsh/skills into \$DSH_HOME/skills
--roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step
--openspec add the agents skill adapter to the current OpenSpec project
--superpowers-from DIR sync superpowers skills from DIR (the repo root or its skills/)
Superpowers source, first match wins:
1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE
3. the pinned clone \$DSH_HOME/superpowers at \$DSH_SUPERPOWERS_REF
(default v7.0.1), cloned or updated from \$DSH_SUPERPOWERS_REPO
(default https://github.com/obra/superpowers); offline it is reused
only when already on the ref
4. legacy: the Claude Code plugin cache, then ~/.claude/skills
(raw copy, warns; removal is #66)
Explicit sources are synced in place (no network, no pin enforcement); the
synced tree is verified before anything is copied. Get the default source
once: $CLONE_HINT
Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE
or ask the agent to call plugin_manager install_bundle with that target.
EOF
}
if [ "$#" -eq 0 ]; then
DO_CHECK=1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--check) DO_CHECK=1 ;;
--global) DO_GLOBAL=1 ;;
--skills) DO_SKILLS=1 ;;
--roles) DO_ROLES=1 ;;
--openspec) DO_OPENSPEC=1 ;;
--all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1; DO_ROLES=1 ;;
--superpowers-from)
if [ "$#" -lt 2 ]; then
echo "option --superpowers-from needs a directory" >&2
exit 2
fi
SUPERPOWERS_FROM="$2"
SUPERPOWERS_FROM_SET=1
shift
;;
--superpowers-from=*)
SUPERPOWERS_FROM="${1#*=}"
SUPERPOWERS_FROM_SET=1
;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
# An explicit flag with an empty value is a typo, not "use the default source":
# falling through to legacy would mask exactly the misconfiguration D-2 forbids.
if [ "$SUPERPOWERS_FROM_SET" = 1 ] && [ -z "$SUPERPOWERS_FROM" ]; then
echo "option --superpowers-from needs a non-empty directory" >&2
exit 2
fi
# Normalize a superpowers source: accept the repo root (with skills/) or the
# skills directory itself. Prints the skills directory, returns 1 otherwise.
normalize_superpowers_dir() {
local dir="${1:-}"
[ -n "$dir" ] || return 1
if [ -f "$dir/skills/using-superpowers/SKILL.md" ]; then
printf '%s\n' "$dir/skills"
return 0
fi
if [ -f "$dir/using-superpowers/SKILL.md" ]; then
printf '%s\n' "$dir"
return 0
fi
return 1
}
# The checkout root the sync script consumes: <root>/skills must exist.
# Returns 1 when the skills directory is not part of a checkout layout.
checkout_root() {
local skills_dir="${1:-}"
[ "$(basename "$skills_dir")" = skills ] || return 1
printf '%s\n' "$(dirname "$skills_dir")"
return 0
}
# Resolve the superpowers install source without touching the network.
# Prints "<kind>\t<checkout-root>\t<skills-dir>". Returns 2 when an explicitly
# configured source is invalid or is not a checkout layout (never fall back
# silently; --check and --skills must agree on the verdict), 1 when none is
# found.
superpowers_source() {
local skills="" checkout="" legacy=""
if [ -n "$SUPERPOWERS_FROM" ]; then
skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0
fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0
fi
if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
printf 'pinned\t%s\t%s\n' "$DSH_HOME/superpowers" "$skills"
return 0
fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
# A cache directory without using-superpowers is a broken cache, not a source:
# accepting it would copy 0 skills and report success (the silent empty
# bootstrap this change removes).
if [ -n "$legacy" ] && skills="$(normalize_superpowers_dir "$legacy")"; then
printf 'legacy\t-\t%s\n' "$skills"
return 0
fi
if skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
printf 'legacy\t-\t%s\n' "$skills"
return 0
fi
return 1
}
# Offline ref report for --check: the synced meta first, then git, then config.
# The meta path depends on the source kind (T3.1): pinned reads the clone's
# skills.synced, explicit reads the side staging tree.
report_checkout_ref() {
local checkout="${1:-}" meta="${2:-}" ref="" version="" tag="" sha=""
if [ -n "$meta" ] && [ -f "$meta" ]; then
ref="$(sed -n 's/^[[:space:]]*"ref":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
if [ -n "$version" ]; then
echo "ref ${ref:-unknown} (synced, version $version)"
else
echo "ref ${ref:-unknown} (synced)"
fi
return 0
fi
if [ -n "$checkout" ] && [ -d "$checkout/.git" ]; then
tag="$(git -C "$checkout" describe --tags --exact-match HEAD 2>/dev/null || true)"
if [ -n "$tag" ]; then
echo "ref $tag (git)"
return 0
fi
sha="$(git -C "$checkout" rev-parse --short HEAD 2>/dev/null || true)"
if [ -n "$sha" ]; then
echo "git $sha (ref $DSH_SUPERPOWERS_REF configured)"
return 0
fi
fi
echo "ref $DSH_SUPERPOWERS_REF (configured)"
}
# Ref recorded for an explicit source: the exact tag, else the short sha,
# else "unknown". An explicit source is not pin-enforced, so any of these is
# honest metadata for .sync-meta.json.
source_ref() {
local root="${1:-}" tag="" sha=""
if [ -d "$root/.git" ]; then
tag="$(git -C "$root" describe --tags --exact-match HEAD 2>/dev/null || true)"
[ -n "$tag" ] && { printf '%s\n' "$tag"; return 0; }
sha="$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true)"
[ -n "$sha" ] && { printf '%s\n' "$sha"; return 0; }
fi
printf 'unknown\n'
}
# The effective explicit source directory: the flag first, then the env var.
explicit_source_dir() {
if [ -n "$SUPERPOWERS_FROM" ]; then
printf '%s\n' "$SUPERPOWERS_FROM"
else
printf '%s\n' "${DSH_SUPERPOWERS_SOURCE:-}"
fi
}
# Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then
printf '%s\n' "--superpowers-from $SUPERPOWERS_FROM"
else
printf '%s\n' "DSH_SUPERPOWERS_SOURCE=$DSH_SUPERPOWERS_SOURCE"
fi
}
# Report an invalid explicit source with the accurate reason: either the
# source has no using-superpowers/SKILL.md, or it is not a checkout layout
# (both are verdict 2, and --check and --skills must fail the same way).
report_invalid_explicit_source() {
local dir="" skills=""
dir="$(explicit_source_dir)"
if [ -n "$dir" ] && skills="$(normalize_superpowers_dir "$dir")"; then
if ! checkout_root "$skills" >/dev/null 2>&1; then
echo "FAIL: explicit superpowers source must be a checkout root or its skills/ directory: $(explicit_source_name)" >&2
return 0
fi
fi
echo "FAIL: explicit superpowers source is invalid: $(explicit_source_name) has no using-superpowers/SKILL.md (expected <dir>/skills/... or <dir>/...)" >&2
}
check() {
local failed=0
for cmd in python3 git node; do
if command -v "$cmd" >/dev/null 2>&1; then
echo "ok: $cmd ($("$cmd" --version 2>&1 | head -1))"
else
echo "WARN: $cmd not found" >&2
failed=1
fi
done
if [ -f "$ROOT/hooks/harness_target.py" ]; then
echo "ok: hooks/harness_target.py"
else
echo "FAIL: $ROOT/hooks/harness_target.py is missing" >&2
failed=1
fi
if [ -f "$ROOT/global/CLAUDE.md" ]; then
echo "ok: global/CLAUDE.md"
else
echo "FAIL: $ROOT/global/CLAUDE.md is missing" >&2
failed=1
fi
if [ -f "$BUNDLE/index.js" ] && [ -f "$BUNDLE/cordis.patch.yml" ]; then
echo "ok: bundle $BUNDLE"
else
echo "FAIL: bundle is incomplete: $BUNDLE" >&2
failed=1
fi
if [ -f "$BUNDLE/hooks/guard-main.sh" ] && [ -f "$BUNDLE/hooks/require-plan.sh" ]; then
echo "ok: hook bridge"
else
echo "FAIL: hook bridge is missing (is dsh/bundle/hooks a symlink to ../../hooks?)" >&2
failed=1
fi
if [ -f "$ROOT/dsh/subagent-models.yml" ]; then
echo "ok: subagent overlay ($ROOT/dsh/subagent-models.yml)"
else
echo "WARN: subagent overlay missing; SDD runs on the session model at depth 1" >&2
fi
local patch found=0
for patch in "$DSH_HOME"/profiles/*/cordis.patch.yml; do
[ -f "$patch" ] || continue
if grep -qs 'maxDepth: 2' "$patch"; then
found=1
break
fi
done
if [ "$found" = 1 ]; then
echo "ok: subagent maxDepth=2 ($patch)"
else
echo "WARN: subagent maxDepth is not 2 in any profile patch; SDD mode 2 is unavailable" >&2
fi
if command -v openspec >/dev/null 2>&1; then
echo "ok: openspec $(openspec --version 2>/dev/null | head -1)"
else
echo "WARN: openspec not found; OpenSpec workflows are unavailable" >&2
fi
if [ -f "$ROOT/.agents/skills/openspec-propose/SKILL.md" ]; then
echo "ok: openspec agents skills ($ROOT/.agents/skills)"
else
echo "WARN: .agents/skills missing; run --openspec in the project" >&2
fi
# --check is offline: report the pin config and whatever source exists,
# never clone or fetch.
echo "ok: superpowers ref ${DSH_SUPERPOWERS_REF} (DSH_SUPERPOWERS_REPO=${DSH_SUPERPOWERS_REPO})"
local src="" rc=0 kind checkout skills_dir
src="$(superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source
failed=1
elif [ "$rc" -ne 0 ]; then
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "WARN: no superpowers source; installed skills in $DSH_HOME/skills are used" >&2
else
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2
fi
else
kind="${src%%$'\t'*}"; src="${src#*$'\t'}"
checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}"
if [ "$kind" = legacy ]; then
echo "WARN: legacy superpowers source ($skills_dir); prefer --superpowers-from or $DSH_HOME/superpowers" >&2
elif [ "$kind" = explicit ]; then
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$EXPLICIT_OUT/.sync-meta.json")"
else
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$PINNED_OUT/.sync-meta.json")"
fi
fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "ok: dsh skills ($DSH_HOME/skills)"
elif [ "$rc" -eq 0 ]; then
echo "WARN: superpowers skills are not copied yet; run --skills" >&2
fi
if [ -f "$DSH_HOME/skills/role/SKILL.md" ]; then
echo "ok: role skill"
else
echo "WARN: role skill not installed; run --skills" >&2
fi
if [ -f "$DSH_HOME/roles/dev.md" ]; then
echo "ok: roles ($DSH_HOME/roles)"
elif [ -f "$HOME/.claude/roles/dev.md" ]; then
echo "ok: roles (~/.claude/roles; run --roles to copy)"
elif [ -f "$ROOT/roles/dev.md" ]; then
echo "ok: roles ($ROOT/roles; run --roles to install)"
else
echo "WARN: roles not found; /role will fail until --roles is run" >&2
fi
if [ -f "$DSH_HOME/roles/dev.md" ] && grep -q 'В DSH-сессии исполнение плана' "$DSH_HOME/roles/dev.md"; then
echo "ok: role dsh notes"
else
echo "WARN: role has no DSH SDD notes; run --roles" >&2
fi
echo "DSH_HOME=$DSH_HOME"
return "$failed"
}
install_global() {
mkdir -p "$DSH_HOME"
local target="$DSH_HOME/AGENTS.md"
if [ -f "$target" ]; then
local backup="$target.bak.$(date +%s)"
cp "$target" "$backup"
echo "backup: $backup"
fi
{
echo "# DeepSeek Harness global instructions"
echo
echo "Installed from $ROOT/global/CLAUDE.md by dsh/install.sh."
echo "The dsh-harness bundle enforces guard-main/require-plan and provides the /role command."
echo "DSH adapter: the model loads roles with skill role; /role is the user-facing command."
echo "DSH adapter: there is no SessionStart role hint; infer the role from the first message."
echo "DSH adapter: role files are ~/.dsh/roles (installed by --roles); legacy Claude Code role locations are a migration fallback."
echo "DSH adapter: superpowers skills are ~/.dsh/skills (installed by --skills from --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers); legacy Claude Code locations are a migration fallback."
echo "DSH adapter: a project CLAUDE.md/AGENTS.md is the instruction-file format of this harness; it does not imply a Claude Code runtime."
echo "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore."
echo
# The shared global/CLAUDE.md is also read by Claude Code and must not
# change; only the installed DSH copy points at the DSH role layer.
python3 - "$ROOT/global/CLAUDE.md" <<'PY'
import sys
from pathlib import Path
text = Path(sys.argv[1]).read_text(encoding="utf-8")
sys.stdout.write(text.replace("~/.claude/roles", "~/.dsh/roles"))
PY
} > "$target"
echo "written: $target"
}
# The default source: a clone at $DSH_HOME/superpowers pinned to $REF.
# Returns 0 when the clone is usable at the ref, 1 when a fresh clone failed
# (the caller may fall back to legacy), 2 when pre-existing content blocks the
# pin (hard fail: the directory is never touched).
ensure_pinned_clone() {
local dir="$1" ref="$2"
if [ -e "$dir" ] && [ ! -d "$dir" ]; then
echo "FAIL: $dir exists and is not a directory; the pinned clone needs it" >&2
return 2
fi
if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then
if [ ! -d "$dir/.git" ] || ! git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "FAIL: $dir has no usable .git: not a pinned superpowers clone" >&2
echo "FAIL: move it away and run: $CLONE_HINT" >&2
return 2
fi
# A foreign git repository must never be mutated by fetch/checkout: a real
# superpowers clone always carries skills/using-superpowers/SKILL.md (I-1).
# The marker also holds for the moved-tag flow — skills are present in
# every pinned clone.
if [ ! -f "$dir/skills/using-superpowers/SKILL.md" ]; then
echo "FAIL: $dir is not a superpowers clone (no skills/using-superpowers/SKILL.md); left untouched" >&2
echo "FAIL: move it away or use --superpowers-from <dir>" >&2
return 2
fi
# Online: refresh the pin. Fetching from $DSH_SUPERPOWERS_REPO (not from
# the clone's origin) makes a repo change take effect on the next run
# (T3.3); the force refspec updates the LOCAL tag as well as FETCH_HEAD, so
# a later offline run still sees HEAD on the ref (T3.5), and checking out
# FETCH_HEAD handles a moved tag, which `git checkout <ref>` (resolving the
# stale local ref) would miss. The plain "$ref" fallback covers non-tag
# refs (e.g. a branch pin).
if git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "+refs/tags/$ref:refs/tags/$ref" 2>/dev/null ||
git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "$ref" 2>/dev/null; then
if ! git -C "$dir" checkout -q FETCH_HEAD; then
echo "FAIL: checkout of the fetched ref failed in $dir" >&2
return 2
fi
echo "ok: superpowers clone at $ref ($dir)"
return 0
fi
# Offline: reuse only when HEAD is exactly at the pinned ref.
if git -C "$dir" rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 &&
[ "$(git -C "$dir" rev-parse HEAD)" = "$(git -C "$dir" rev-parse "$ref^{commit}")" ]; then
echo "WARN: offline; reusing the existing clone at $ref ($dir)" >&2
return 0
fi
echo "FAIL: cannot update $dir to $ref (offline?) and it is not on that ref" >&2
echo "FAIL: $CLONE_HINT" >&2
return 2
fi
# Fresh (absent or empty): clone pinned; a failed clone leaves nothing (RF5).
if ! git clone --depth 1 --branch "$ref" "$DSH_SUPERPOWERS_REPO" "$dir"; then
echo "FAIL: $CLONE_HINT failed" >&2
rm -rf "$dir"
return 1
fi
echo "ok: cloned superpowers at $ref into $dir"
return 0
}
# Sync + verify the checkout into $3 (staging out). Returns 1 (nothing
# copied) on any problem; the synced tree is the caller's copy source.
sync_skills() {
local checkout="$1" ref="$2" out="$3" verified="" verified_n="" verify_rc=0
if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$out" --ref "$ref"; then
echo "FAIL: superpowers sync failed ($checkout)" >&2
return 1
fi
verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$out" 2>&1)" || verify_rc=$?
printf '%s\n' "$verified"
if [ "$verify_rc" -ne 0 ]; then
echo "FAIL: superpowers verify failed; nothing copied" >&2
return 1
fi
# T2.5: a verified tree with zero skills (exit 0) must never be installed.
verified_n="$(printf '%s\n' "$verified" | sed -n 's/.*verified \([0-9][0-9]*\) skills.*/\1/p' | tail -n 1)"
case "$verified_n" in
''|*[!0-9]*) echo "FAIL: verify reported no usable skill count; nothing copied" >&2; return 1 ;;
esac
if [ "$verified_n" -eq 0 ]; then
echo "FAIL: verify reported 0 skills; a tree without skills is never installed" >&2
return 1
fi
return 0
}
# 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.
copy_skills_from() {
local src="$1" label="${2:-$1}" count=0 dir name dest
for dir in "$src"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
}
install_skills() {
mkdir -p "$DSH_HOME/skills"
# Explicit source: sync it directly, no network, no pin enforcement.
local explicit_dir=""
if [ -n "$SUPERPOWERS_FROM" ]; then
explicit_dir="$SUPERPOWERS_FROM"
elif [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
explicit_dir="$DSH_SUPERPOWERS_SOURCE"
fi
if [ -n "$explicit_dir" ]; then
local skills_dir="" checkout=""
skills_dir="$(normalize_superpowers_dir "$explicit_dir")" || {
report_invalid_explicit_source
return 1
}
checkout="$(checkout_root "$skills_dir")" || {
report_invalid_explicit_source
return 1
}
# T3.1: the staging tree lives beside the clone dir, never inside it.
if ! sync_skills "$checkout" "$(source_ref "$checkout")" "$EXPLICIT_OUT"; then
return 1
fi
copy_skills_from "$EXPLICIT_OUT" "$checkout"
return 0
fi
# Default source: the pinned clone at $DSH_HOME/superpowers.
local ensure_rc=0
ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$?
if [ "$ensure_rc" -eq 0 ]; then
if ! sync_skills "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" "$PINNED_OUT"; then
return 1
fi
copy_skills_from "$PINNED_OUT" "$DSH_HOME/superpowers"
return 0
fi
if [ "$ensure_rc" -ge 2 ]; then
return 1
fi
# A fresh clone failed (offline): legacy sources stay raw with WARN (D-4).
local legacy="" legacy_skills=""
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$legacy" ] && legacy_skills="$(normalize_superpowers_dir "$legacy")"; then
:
elif legacy_skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
:
else
echo "FAIL: no superpowers source: $CLONE_HINT failed and no legacy source found" >&2
echo "FAIL: clone it once: $CLONE_HINT" >&2
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2
return 1
fi
echo "WARN: legacy superpowers source $legacy_skills; prefer --superpowers-from or $DSH_HOME/superpowers" >&2
copy_skills_from "$legacy_skills"
return 0
}
install_local_skills() {
local src="$ROOT/dsh/skills"
[ -d "$src" ] || return 0
mkdir -p "$DSH_HOME/skills"
local count=0
local dir name dest
for dir in "$src"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "copied $count local skills into $DSH_HOME/skills"
}
install_roles() {
mkdir -p "$DSH_HOME/roles"
local src="$ROOT/roles"
local count=0
local file name
for file in "$src"/*.md; do
[ -f "$file" ] || continue
name="$(basename "$file")"
cp "$file" "$DSH_HOME/roles/$name"
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "WARN: no role files found in $src" >&2
return 0
fi
echo "installed $count roles into $DSH_HOME/roles"
}
# The DSH SDD note belongs to the installed role, not to the shared roles/ layer:
# in Claude Code the note does not apply, so it is written here, after the copy,
# and is idempotent (the source file is overwritten on every --roles run).
install_role_dsh_notes() {
local role="$DSH_HOME/roles/dev.md"
[ -f "$role" ] || return 0
python3 - "$role" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
marker = "В DSH-сессии исполнение плана"
if marker in text:
print("ok: role dsh notes already present")
raise SystemExit(0)
note = (
"- В DSH-сессии исполнение плана идёт по skill `sdd`, а не "
"`subagent-driven-development`: асинхронный `subagent` (бриф и отчёт файлами, "
"ожидание завершением хода и уведомлением, ledger "
"`.superpowers/sdd/<plan>/progress.md` с `## Findings`, fix-loop до 5 раундов). "
"Режим контроллера — по числу параллельных планов: один план — главная сессия, "
"несколько — фоновый контроллер (нужен `maxDepth: 2` из "
"`dsh/subagent-models.yml`). В Claude Code строка не действует: там исходный "
"`subagent-driven-development`. "
"Бандл гейтит цикл: builder-check обязателен до передачи дизайна владельцу "
"(иначе `/approve design` откажет); план пишется только после `/approve design`; "
"sdd стартует только после `/approve plan` (гейты A/B/C, "
"`BLOCKED [approval-gates]`). Пропуск — только явное решение владельца."
)
anchor = "- Режим по умолчанию: subagent-driven-development."
lines = text.splitlines(keepends=True)
for i, line in enumerate(lines):
if line.startswith(anchor):
lines.insert(i + 1, note + "\n")
break
else:
lines.append((("" if text.endswith("\n") else "\n") + note + "\n"))
path.write_text("".join(lines), encoding="utf-8")
print("ok: role dsh notes inserted")
PY
}
install_openspec() {
local dir="${1:-$PWD}"
if ! command -v openspec >/dev/null 2>&1; then
echo "FAIL: openspec CLI not found; install it first" >&2
return 1
fi
if [ ! -d "$dir/openspec" ]; then
echo "FAIL: $dir has no openspec/ root; run: openspec init --tools agents" >&2
return 1
fi
if ! ( cd "$dir" && openspec init --tools agents ); then
echo "FAIL: openspec init --tools agents failed in $dir" >&2
return 1
fi
echo "ok: openspec agents adapter in $dir"
}
print_bundle_step() {
cat <<EOF
Bundle: $BUNDLE
Install it in the current DSH profile (the Desktop profile is Electron-managed):
- GUI: Plugins -> install a bundle from the directory above;
- or ask the agent to call plugin_manager install_bundle with target "$BUNDLE".
Do not edit \$DSH_HOME/profiles/desktop/package.json or cordis.patch.yml by hand.
Optional: add CodeGraph as an MCP server from dsh/mcp.codegraph.yml.
EOF
}
status=0
if [ "$DO_CHECK" = 1 ]; then
check || status=1
fi
if [ "$DO_GLOBAL" = 1 ]; then
install_global
fi
if [ "$DO_SKILLS" = 1 ]; then
# Local skills do not depend on superpowers: install them even when the
# superpowers source is missing (the FAIL is reported by the exit status).
install_skills || status=1
install_local_skills
fi
if [ "$DO_ROLES" = 1 ]; then
install_roles
install_role_dsh_notes
fi
# A failed --openspec step must not abort the other steps: the explicit return 1
# becomes the script status here, like check() above.
if [ "$DO_OPENSPEC" = 1 ]; then
install_openspec "$PWD" || status=1
fi
if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ] || [ "$DO_ROLES" = 1 ]; then
print_bundle_step
fi
exit "$status"