feat(install): level-aware casan init + merge-safe adoption + hub guardrail

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>
This commit is contained in:
thanhnv
2026-07-23 22:00:46 +07:00
co-authored by Claude Opus 4.8
parent 8450f8ca1a
commit ff4e9d5a53
8 changed files with 430 additions and 22 deletions
+48 -3
View File
@@ -15,12 +15,34 @@
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=""
@@ -50,6 +72,7 @@ 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 ──────────────────────
@@ -67,11 +90,21 @@ copy_tree() { # <relpath>
cp -R "$SRC/$1/." "$DEST/$1/"
fi
}
# L1 core: harness + CLI + installer + level manifest.
copy_tree "packages/casan-harness"
copy_tree "packages/casan-devkit"
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
@@ -92,7 +125,15 @@ cat > "$CASAN_HOME/bin/casan" <<'LAUNCH'
#!/usr/bin/env bash
# CASAN global launcher — resolves the shared harness and the CURRENT project.
set -uo pipefail
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
# 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"
@@ -127,6 +168,10 @@ else
log "launcher : $CASAN_HOME/bin/casan"
log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)"
fi
log "next: cd <your-project> && casan init"
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