Answers the 3 adoption questions (Plan-21 follow-up):
1) LEVEL SELECTION (4 packaging levels, packaging/levels.json):
- install.sh --level core|devkit; platform refused (preview service),
enterprise refused (future). Level recorded in .casan-level.
- casan init --level 1..4: L1=gate+Plan-20 hooks only; L2=+CI+domain-pack;
L3=L2 base+preview note; L4=refused. New `casan level show|set`.
- levels.json core now includes adapters/ + schemas/ + install scripts.
2) EXISTING SHELLS (agents/skills): init MERGES Plan-20 hooks into an existing
.claude/settings.json and .codex/{hooks.json,config.toml} idempotently
instead of clobbering — preserves the project's own hooks/agents/skills and
unrelated keys. Re-running never duplicates the CASAN hook.
3) NO RE-INDEX / NO SHELL REWRITE: init only adds config; it does not parse or
index code and does not rewrite the project shell.
Safety fixes after a test accidentally ran init in the real repo:
- launcher shim now SELF-LOCATES its install from its own path (no ambient
CASAN_HOME cross-talk).
- casan init REFUSES to adopt a CASAN source hub into itself (--force to
override), so the Plan-20 hooks can't block the developing agent.
- test always runs init inside throwaway dirs; +source-hub guard test.
hybrid-install-tests.sh: 41/41 PASS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
7.6 KiB
Bash
Executable File
178 lines
7.6 KiB
Bash
Executable File
#!/bin/sh
|
|
# CASAN global installer (Plan-21 hybrid model) — macOS / Linux.
|
|
#
|
|
# curl -fsSL https://<your-gitea>/admin/casan5/raw/branch/main/install.sh | sh
|
|
# # or, from a local CASAN source checkout:
|
|
# sh install.sh
|
|
#
|
|
# Installs the CASAN harness ONCE under $CASAN_HOME (default ~/.casan), puts a
|
|
# `casan` launcher on PATH, and records a content-integrity hash of the gate code.
|
|
# Projects then run `casan init` to adopt CASAN with only per-project config —
|
|
# the harness itself is NOT copied into every repo (that is the hybrid model).
|
|
#
|
|
# POSIX sh (runs under `curl | sh`). The harness itself needs bash + python3 to
|
|
# run; this bootstrap only needs sh, cp and python3.
|
|
set -eu
|
|
|
|
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
|
|
LEVEL="${CASAN_LEVEL:-devkit}" # core | devkit (platform=preview service, enterprise=future)
|
|
|
|
# Parse flags (also accept `--level X`, e.g. `... | sh -s -- --level core`).
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--level) LEVEL="$2"; shift 2 ;;
|
|
--level=*) LEVEL="${1#*=}"; shift ;;
|
|
-h|--help) sed -n '2,12p' "$0" 2>/dev/null; exit 0 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
log() { printf '[casan-install] %s\n' "$1"; }
|
|
die() { printf '[casan-install] ERROR: %s\n' "$1" >&2; exit 1; }
|
|
|
|
command -v python3 >/dev/null 2>&1 || die "python3 is required on PATH."
|
|
|
|
# Levels are cumulative (packaging/levels.json is the source of truth). The tool
|
|
# installer handles the two ADOPT-INTO-PROJECT levels; L3/L4 are separate.
|
|
case "$LEVEL" in
|
|
core|devkit) ;;
|
|
platform)
|
|
die "Level 3 (platform) is a PREVIEW service, not a per-project tool install. Install the CLI base with '--level devkit', then deploy the platform separately (scripts/package-release.sh platform, docs/packaging)." ;;
|
|
enterprise)
|
|
printf '[casan-install] Level 4 (enterprise) is FUTURE / not shipped — refusing (see packaging/levels.json).\n' >&2; exit 3 ;;
|
|
*) die "unknown --level '$LEVEL' (valid: core | devkit)." ;;
|
|
esac
|
|
|
|
# ── 1) Locate the source (local checkout, explicit CASAN_SRC, or a dist tarball)
|
|
SRC=""
|
|
CLEANUP=""
|
|
if [ -n "${CASAN_SRC:-}" ] && [ -d "$CASAN_SRC/packages/casan-harness" ]; then
|
|
SRC="$CASAN_SRC"
|
|
elif [ -d "./packages/casan-harness" ]; then
|
|
SRC="$(pwd)"
|
|
elif [ -n "${CASAN_DIST_URL:-}" ]; then
|
|
command -v curl >/dev/null 2>&1 || die "curl is required to fetch CASAN_DIST_URL."
|
|
TMP="$(mktemp -d)"
|
|
CLEANUP="$TMP"
|
|
log "downloading CASAN from $CASAN_DIST_URL"
|
|
curl -fsSL "$CASAN_DIST_URL" -o "$TMP/casan.tar.gz" || die "download failed: $CASAN_DIST_URL"
|
|
tar -xzf "$TMP/casan.tar.gz" -C "$TMP" || die "extract failed."
|
|
# Accept either a flat tarball or a single top-level dir.
|
|
if [ -d "$TMP/packages/casan-harness" ]; then
|
|
SRC="$TMP"
|
|
else
|
|
SRC="$(find "$TMP" -maxdepth 2 -type d -name casan-harness -exec dirname {} \; -quit)"
|
|
SRC="$(dirname "$SRC" 2>/dev/null || true)"
|
|
[ -n "$SRC" ] && [ -d "$SRC/packages/casan-harness" ] || die "tarball has no packages/casan-harness."
|
|
fi
|
|
else
|
|
die "no CASAN source found. Run from a CASAN checkout, set CASAN_SRC=<dir>, or set CASAN_DIST_URL=<tarball>."
|
|
fi
|
|
|
|
VERSION="$(cat "$SRC/VERSION" 2>/dev/null || echo "0.0.0")"
|
|
log "source : $SRC"
|
|
log "version : $VERSION"
|
|
log "level : $LEVEL (core=harness+CLI, devkit=+adoption tooling)"
|
|
log "install : $CASAN_HOME"
|
|
|
|
# ── 2) Copy harness + devkit + CLI into a versioned dir ──────────────────────
|
|
DEST="$CASAN_HOME/versions/$VERSION"
|
|
rm -rf "$DEST"
|
|
mkdir -p "$DEST/packages" "$DEST/bin"
|
|
|
|
copy_tree() { # <relpath>
|
|
[ -e "$SRC/$1" ] || return 0
|
|
if command -v rsync >/dev/null 2>&1; then
|
|
rsync -a --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
|
|
--exclude='*.log' "$SRC/$1/" "$DEST/$1/"
|
|
else
|
|
mkdir -p "$DEST/$1"
|
|
cp -R "$SRC/$1/." "$DEST/$1/"
|
|
fi
|
|
}
|
|
# L1 core: harness + CLI + installer + level manifest.
|
|
copy_tree "packages/casan-harness"
|
|
cp "$SRC/bin/casan" "$DEST/bin/casan"
|
|
chmod +x "$DEST/bin/casan"
|
|
cp "$SRC/VERSION" "$DEST/VERSION" 2>/dev/null || printf '%s\n' "$VERSION" > "$DEST/VERSION"
|
|
for f in install.sh install.ps1; do [ -f "$SRC/$f" ] && cp "$SRC/$f" "$DEST/$f"; done
|
|
mkdir -p "$DEST/packaging"; [ -f "$SRC/packaging/levels.json" ] && cp "$SRC/packaging/levels.json" "$DEST/packaging/levels.json"
|
|
|
|
# L2 devkit: adoption tooling (templates, domain-pack, casan init, package-release).
|
|
if [ "$LEVEL" = "devkit" ]; then
|
|
copy_tree "packages/casan-devkit"
|
|
mkdir -p "$DEST/scripts"; [ -f "$SRC/scripts/package-release.sh" ] && cp "$SRC/scripts/package-release.sh" "$DEST/scripts/package-release.sh"
|
|
[ -d "$SRC/docs/packaging" ] && { mkdir -p "$DEST/docs"; cp -R "$SRC/docs/packaging" "$DEST/docs/packaging"; }
|
|
fi
|
|
printf '%s\n' "$LEVEL" > "$DEST/.casan-level"
|
|
|
|
# Prune stray caches the copy may have carried.
|
|
find "$DEST" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
|
find "$DEST" -name '*.pyc' -delete 2>/dev/null || true
|
|
|
|
# ── 3) Record the gate-code integrity hash (pin+verify anchor) ───────────────
|
|
HASHER="$DEST/packages/casan-harness/scripts/python/harness_hash.py"
|
|
HHASH="$(python3 "$HASHER" compute "$DEST/packages/casan-harness")" || die "failed to compute harness hash."
|
|
printf '%s\n' "$HHASH" > "$DEST/.harness-hash"
|
|
log "integrity: $HHASH"
|
|
|
|
# ── 4) Point `current` at this version ───────────────────────────────────────
|
|
ln -sfn "$DEST" "$CASAN_HOME/current"
|
|
|
|
# ── 5) Write the global launcher (shim) ──────────────────────────────────────
|
|
mkdir -p "$CASAN_HOME/bin"
|
|
cat > "$CASAN_HOME/bin/casan" <<'LAUNCH'
|
|
#!/usr/bin/env bash
|
|
# CASAN global launcher — resolves the shared harness and the CURRENT project.
|
|
set -uo pipefail
|
|
# Self-locate THIS install from the launcher's own path (…/<home>/bin/casan) so
|
|
# invoking a specific launcher always uses ITS install, regardless of any ambient
|
|
# CASAN_HOME env. Fall back to CASAN_HOME/$HOME only if self-resolution fails.
|
|
_self="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
if [[ -d "$_self/current" || -d "$_self/versions" ]]; then
|
|
CASAN_HOME="$_self"
|
|
else
|
|
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
|
|
fi
|
|
CUR="$CASAN_HOME/current"
|
|
[[ -d "$CUR" ]] || { echo "casan: no install at $CUR (run install.sh)" >&2; exit 1; }
|
|
export CASAN_HARNESS_ROOT="$CUR/packages/casan-harness"
|
|
export CASAN_DEVKIT_ROOT="$CUR/packages/casan-devkit"
|
|
export CASAN_INSTALL_ROOT="$CUR"
|
|
# Project (app) root = nearest ancestor of CWD carrying a .casan/.specify marker.
|
|
if [[ -z "${CASAN_APP_ROOT:-}" ]]; then
|
|
_d="$PWD"
|
|
while [[ "$_d" != "/" && -n "$_d" ]]; do
|
|
if [[ -d "$_d/.casan" || -d "$_d/.specify" ]]; then export CASAN_APP_ROOT="$_d"; break; fi
|
|
_d="$(dirname "$_d")"
|
|
done
|
|
fi
|
|
exec bash "$CUR/bin/casan" "$@"
|
|
LAUNCH
|
|
chmod +x "$CASAN_HOME/bin/casan"
|
|
|
|
# ── 6) Put the launcher on PATH (skip with CASAN_NO_PATH_LINK=1, e.g. in tests)
|
|
LINKED=""
|
|
if [ "${CASAN_NO_PATH_LINK:-0}" != "1" ]; then
|
|
for d in "$HOME/.local/bin" "/usr/local/bin"; do
|
|
if [ -d "$d" ] && printf '%s' ":$PATH:" | grep -q ":$d:"; then
|
|
if ln -sf "$CASAN_HOME/bin/casan" "$d/casan" 2>/dev/null; then LINKED="$d/casan"; break; fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
log "installed CASAN $VERSION"
|
|
if [ -n "$LINKED" ]; then
|
|
log "launcher : $LINKED (on PATH)"
|
|
else
|
|
log "launcher : $CASAN_HOME/bin/casan"
|
|
log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)"
|
|
fi
|
|
if [ "$LEVEL" = "devkit" ]; then
|
|
log "next: cd <your-project> && casan init (adopt CASAN, config only)"
|
|
else
|
|
log "level core installed: 'casan run/gate/verify' available. Re-run with --level devkit to get 'casan init'."
|
|
fi
|
|
|
|
[ -n "$CLEANUP" ] && rm -rf "$CLEANUP" || true
|