claude-harness/dsh/install.sh
2026-10-24 21:35:56 +04:00

590 lines
20 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}"
DO_CHECK=0
DO_GLOBAL=0
DO_SKILLS=0
DO_ROLES=0
DO_OPENSPEC=0
SUPERPOWERS_FROM=""
SUPERPOWERS_FROM_SET=0
SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
CLONE_HINT="git clone --depth 1 $SUPERPOWERS_REPO $DSH_HOME/superpowers"
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
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills 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 copy superpowers skills from DIR (the repo root or its skills/)
Superpowers source, first match wins:
1. --superpowers-from DIR
2. \$DSH_SUPERPOWERS_SOURCE
3. git: clone or update \$DSH_HOME/superpowers from \$DSH_SUPERPOWERS_REPO
(default: https://github.com/obra/superpowers); no network when 1 or 2 is used
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns)
Offline: an existing git checkout is reused with a warning; a manual copy in
\$DSH_HOME/superpowers without .git is used as-is and is never updated.
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
}
# Update (or create) the managed git checkout and print "<dir>\tgit:<sha>".
# A failed update keeps the cached checkout with a WARN; a failed first clone
# removes the directory and returns 1 so the caller can try legacy sources.
git_superpowers_source() {
local target="$DSH_HOME/superpowers"
local dir sha
if ! command -v git >/dev/null 2>&1; then
echo "WARN: git not found; cannot fetch superpowers from $SUPERPOWERS_REPO" >&2
return 1
fi
if [ -d "$target/.git" ]; then
# The configured repo must win over the URL recorded at clone time: a mirror
# switch (or a test fixture) has to affect an existing checkout as well.
local origin_url
origin_url="$(git -C "$target" remote get-url origin 2>/dev/null || true)"
if [ "$origin_url" != "$SUPERPOWERS_REPO" ]; then
git -C "$target" remote set-url origin "$SUPERPOWERS_REPO" >/dev/null 2>&1 || true
fi
if ! { git -C "$target" fetch --depth 1 origin >/dev/null 2>&1 \
&& git -C "$target" reset --hard FETCH_HEAD >/dev/null 2>&1; }; then
echo "WARN: cannot update superpowers ($SUPERPOWERS_REPO); using the cached checkout $target" >&2
fi
elif [ -d "$target" ]; then
# A manual copy from dsh-install-source (#49): usable, but not ours to update.
if dir="$(normalize_superpowers_dir "$target")"; then
echo "WARN: $target is not a git checkout; it will not be updated (use --superpowers-from to pin, or replace it with a clone)" >&2
printf '%s\tpinned\n' "$dir"
return 0
fi
echo "WARN: $target is not a git checkout and has no using-superpowers/SKILL.md" >&2
return 1
else
mkdir -p "$(dirname "$target")"
if ! git clone --depth 1 "$SUPERPOWERS_REPO" "$target" >/dev/null 2>&1; then
rm -rf "$target"
echo "WARN: git clone $SUPERPOWERS_REPO failed (offline?); no managed checkout" >&2
return 1
fi
fi
if ! dir="$(normalize_superpowers_dir "$target")"; then
echo "WARN: $target has no using-superpowers/SKILL.md after update" >&2
return 1
fi
sha="$(git -C "$target" rev-parse --short HEAD 2>/dev/null || true)"
if [ -n "$sha" ]; then
printf '%s\tgit:%s\n' "$dir" "$sha"
else
printf '%s\tgit\n' "$dir"
fi
return 0
}
# Network-capable resolver used by --skills. Explicit sources win and never
# touch git; then the managed checkout; then legacy Claude Code locations.
# Print "<skills-dir>\t<kind>". Returns 2 when an explicitly configured source
# is invalid (never fall back silently), 1 when none is found.
superpowers_source() {
local dir legacy
if [ -n "$SUPERPOWERS_FROM" ]; then
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if git_superpowers_source; then
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" ] && dir="$(normalize_superpowers_dir "$legacy")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
return 1
}
# Offline resolver used by --check: it never clones, fetches or resets.
# Same output format, plus git:<sha> for a managed checkout.
static_superpowers_source() {
local dir legacy sha target="$DSH_HOME/superpowers"
if [ -n "$SUPERPOWERS_FROM" ]; then
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
printf '%s\texplicit\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$target")"; then
if [ -d "$target/.git" ]; then
sha="$(git -C "$target" rev-parse --short HEAD 2>/dev/null || true)"
if [ -n "$sha" ]; then
printf '%s\tgit:%s\n' "$dir" "$sha"
else
printf '%s\tgit\n' "$dir"
fi
else
printf '%s\tpinned\n' "$dir"
fi
return 0
fi
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
printf '%s\tlegacy\n' "$dir"
return 0
fi
return 1
}
# 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_invalid_explicit_source() {
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
local src="" rc=0 kind sha
# --check must stay offline: this resolver reads the existing checkout only.
src="$(static_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 --skills to clone $SUPERPOWERS_REPO" >&2
fi
else
kind="${src##*$'\t'}"
src="${src%%$'\t'*}"
case "$kind" in
legacy)
echo "WARN: legacy superpowers source ($src); prefer a git checkout or --superpowers-from" >&2
;;
pinned)
echo "WARN: superpowers source $src is not a git checkout; --skills will not update it" >&2
;;
git:*)
sha="${kind#git:}"
echo "ok: superpowers source ($src, git $sha)"
;;
git)
echo "ok: superpowers source ($src, git)"
;;
*)
echo "ok: superpowers source ($src)"
;;
esac
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"
}
install_skills() {
mkdir -p "$DSH_HOME/skills"
local line="" rc=0 source kind count=0
line="$(superpowers_source)" || rc=$?
if [ "$rc" -eq 2 ]; then
report_invalid_explicit_source
return 1
fi
if [ "$rc" -ne 0 ]; then
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
echo "WARN: no superpowers source found; keeping installed skills in $DSH_HOME/skills" >&2
echo "WARN: next time run --skills (git source: $SUPERPOWERS_REPO) or pass --superpowers-from" >&2
return 0
fi
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2
echo "FAIL: git source $SUPERPOWERS_REPO is unavailable; pass --superpowers-from <dir> or set DSH_SUPERPOWERS_REPO" >&2
echo "FAIL: local skills (role, sdd) are installed by this run" >&2
return 1
fi
kind="${line##*$'\t'}"
source="${line%%$'\t'*}"
local sha=""
case "$kind" in
legacy)
echo "WARN: legacy superpowers source $source; prefer a git checkout or --superpowers-from" >&2
;;
git:*)
sha="${kind#git:}"
;;
esac
local dir name dest
for dir in "$source"/*; 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
if [ -n "$sha" ]; then
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source, git $sha)"
else
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)"
fi
}
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"