feat(scripts): install.sh — orchestrates project/user mode sync with plugins+MCP
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install brain artifacts to target (project consumer or user-level ~/.claude)
|
||||
#
|
||||
# Usage:
|
||||
# install.sh --target=<path> --version=<tag>
|
||||
# [--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)"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user