684 lines
25 KiB
Bash
Executable File
684 lines
25 KiB
Bash
Executable File
#!/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
|
||
# 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`."
|
||
)
|
||
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"
|