From 6949a9d04708e1cce0df47aaa3d288558db1dc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 11 May 2026 00:58:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20install.sh=20=E2=80=94=20orche?= =?UTF-8?q?strates=20project/user=20mode=20sync=20with=20plugins+MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan correction E: cp -r replaced with find-loop that strips .template suffix on copy. Without this Test 3 (project mode: CLAUDE.md copied) can't pass — fixture creates CLAUDE.md.template, target expects CLAUDE.md. Real intent: brain stores templates as *.template, consumed without suffix. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/install.sh | 140 ++++++++++++++++++++++++++++++++++ scripts/tests/install-test.sh | 51 +++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 scripts/install.sh create mode 100644 scripts/tests/install-test.sh diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..394e2fc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Install brain artifacts to target (project consumer or user-level ~/.claude) +# +# Usage: +# install.sh --target= --version= +# [--dry-run] [--force] +# [--with-plugins=yes|no] [--with-mcp=yes|no] [--skip-secrets] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +BRAIN_ROOT="${BRAIN_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +target="" +version="" +dry_run=0 +force=0 +with_plugins="default" +with_mcp="yes" +skip_secrets=0 + +while [ $# -gt 0 ]; do + case "$1" in + --target=*) target="${1#--target=}" ;; + --version=*) version="${1#--version=}" ;; + --dry-run) dry_run=1 ;; + --force) force=1 ;; + --with-plugins=*) with_plugins="${1#--with-plugins=}" ;; + --with-mcp=*) with_mcp="${1#--with-mcp=}" ;; + --skip-secrets) skip_secrets=1 ;; + *) log_error "Unknown arg: $1"; exit 1 ;; + esac + shift +done + +[ -n "$target" ] || { log_error "--target required"; exit 1; } +[ -n "$version" ] || { log_error "--version required"; exit 1; } + +require_cmd jq || exit 1 +require_cmd git || exit 1 + +# Detect mode +mode="" +if [ "$target" = "$HOME/.claude" ] || [ -d "$target/hooks" ] || [ -f "$target/settings.json" ]; then + mode="user" +elif [ -d "$target/.git" ] || [ -d "$target/docs" ]; then + mode="project" +else + log_error "Cannot detect mode for: $target (expected ~/.claude/ or project dir with docs/)" + exit 4 +fi +log_info "Detected mode: $mode" + +# Default --with-plugins per mode +if [ "$with_plugins" = "default" ]; then + [ "$mode" = "user" ] && with_plugins="yes" || with_plugins="no" +fi + +# Checkout version (skip if BRAIN_ROOT override — tests) +if [ -z "${BRAIN_ROOT_OVERRIDE:-}" ] && [ -d "$BRAIN_ROOT/.git" ]; then + cd "$BRAIN_ROOT" + git tag | grep -q "^$version$" || { log_error "Tag $version not found"; exit 3; } + git diff --quiet || { log_error "Brain repo dirty"; exit 2; } +fi + +if [ "$dry_run" -eq 1 ]; then + log_info "DRY RUN — would install $version to $target ($mode mode)" + exit 0 +fi + +# Backup +if [ "$force" -eq 0 ]; then + backup=$(make_backup_dir "$target") + log_info "Backup: $backup" +fi + +# Copy files per mode +if [ "$mode" = "project" ]; then + src="$BRAIN_ROOT/project-files" + if [ -d "$src" ]; then + # Copy all files; strip .template suffix on the way + while IFS= read -r -d '' f; do + rel="${f#$src/}" + dst="$target/${rel%.template}" + mkdir -p "$(dirname "$dst")" + cp "$f" "$dst" + done < <(find "$src" -type f -print0) + fi + # Special: .mcp.json from template (если ещё нет) + if [ -f "$src/.mcp.json.template" ]; then + if [ -f "$target/.mcp.json" ] && [ "$with_mcp" = "yes" ]; then + bash "$SCRIPT_DIR/lib/merge-mcp.sh" --mode=project "$target/.mcp.json" "$src/.mcp.json.template" + elif [ ! -f "$target/.mcp.json" ] && [ "$with_mcp" = "yes" ]; then + cp "$src/.mcp.json.template" "$target/.mcp.json" + fi + fi +elif [ "$mode" = "user" ]; then + src="$BRAIN_ROOT/user-level-files" + # Hooks + [ -d "$src/hooks" ] && mkdir -p "$target/hooks" && cp "$src/hooks"/*.py "$target/hooks/" 2>/dev/null || true + # settings.json merge + if [ -f "$src/settings-fragment.json" ]; then + bash "$SCRIPT_DIR/lib/merge-settings.sh" "$target/settings.json" "$src/settings-fragment.json" + fi + # MCP merge (user-level: ~/.claude.json) + if [ "$with_mcp" = "yes" ] && [ -f "$src/mcp-user.template.json" ]; then + claude_json="$(dirname "$target")/.claude.json" + bash "$SCRIPT_DIR/lib/merge-mcp.sh" --mode=user "$claude_json" "$src/mcp-user.template.json" + if [ "$skip_secrets" -eq 0 ]; then + bash "$SCRIPT_DIR/lib/setup-secrets.sh" "$claude_json" || log_warn "Some secrets unresolved" + fi + fi + # Plugins + if [ "$with_plugins" = "yes" ]; then + if [ -f "$src/marketplaces.json" ] && [ -f "$src/plugins-manifest.json" ]; then + bash "$SCRIPT_DIR/lib/install-plugins.sh" \ + --marketplaces="$src/marketplaces.json" \ + --manifest="$src/plugins-manifest.json" || exit 6 + fi + fi +fi + +# Write .brain-version +brain_sha="unknown" +if [ -d "$BRAIN_ROOT/.git" ]; then + brain_sha=$(cd "$BRAIN_ROOT" && git rev-parse "$version" 2>/dev/null || echo "unknown") +fi +{ + echo "$version" + echo "sha: $brain_sha" +} > "$target/.brain-version" + +# Run verify +BRAIN_ROOT="$BRAIN_ROOT" bash "$SCRIPT_DIR/verify.sh" --target="$target" || { + log_error "Verification failed after install" + exit 5 +} + +log_info "Install complete: $version → $target ($mode mode)" diff --git a/scripts/tests/install-test.sh b/scripts/tests/install-test.sh new file mode 100644 index 0000000..d67506d --- /dev/null +++ b/scripts/tests/install-test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -u +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +FAILURES=0 +assert_eq() { + if [ "$1" = "$2" ]; then echo "PASS: $3"; else echo "FAIL: $3 (expected '$1', got '$2')"; FAILURES=$((FAILURES + 1)); fi +} + +# Test 1: --dry-run does not modify target +tmpdir=$(mktemp -d) +mkdir "$tmpdir/docs" +echo "marker" > "$tmpdir/marker.txt" + +bash "$SCRIPT_DIR/install.sh" --target="$tmpdir" --version=brain-v1.0 --dry-run --with-plugins=no --with-mcp=no --skip-secrets >/dev/null 2>&1 + +# marker.txt should still exist +[ -f "$tmpdir/marker.txt" ] && assert_eq "yes" "yes" "dry-run preserves target" || assert_eq "yes" "no" "dry-run preserves target" + +rm -rf "$tmpdir" + +# Test 2: missing --target → exit 1 +bash "$SCRIPT_DIR/install.sh" --version=brain-v1.0 >/dev/null 2>&1 +assert_eq "1" "$?" "missing --target returns exit 1" + +# Test 3: install to fresh project target — files copied +tmpdir=$(mktemp -d) +mkdir "$tmpdir/docs" # detect as project mode (has docs/) +# Эмулируем brain repo с минимальным manifest +brain_dir=$(mktemp -d) +mkdir -p "$brain_dir/project-files/docs" "$brain_dir/scripts" +echo "# template" > "$brain_dir/project-files/CLAUDE.md.template" +echo '{"version":"brain-v1.0","files":{}}' > "$brain_dir/manifest.json" +cp "$SCRIPT_DIR/install.sh" "$brain_dir/scripts/" +cp "$SCRIPT_DIR/verify.sh" "$brain_dir/scripts/" +mkdir -p "$brain_dir/scripts/lib" +cp "$SCRIPT_DIR/lib/common.sh" "$brain_dir/scripts/lib/" +# Также делаем чтобы install.sh видел свой BRAIN_ROOT — переопределяем через env +BRAIN_ROOT="$brain_dir" bash "$brain_dir/scripts/install.sh" --target="$tmpdir" --version=brain-v1.0 --with-plugins=no --with-mcp=no --skip-secrets --force >/dev/null 2>&1 + +# Should have CLAUDE.md +[ -f "$tmpdir/CLAUDE.md" ] && assert_eq "yes" "yes" "project mode: CLAUDE.md copied" || assert_eq "yes" "no" "project mode: CLAUDE.md copied" +# Should have .brain-version +[ -f "$tmpdir/.brain-version" ] && assert_eq "yes" "yes" "project mode: .brain-version written" || assert_eq "yes" "no" "project mode: .brain-version written" + +rm -rf "$tmpdir" "$brain_dir" + +echo "---" +echo "Failures: $FAILURES" +exit $FAILURES