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:
co-authored by
Claude Opus 4.8
parent
8450f8ca1a
commit
ff4e9d5a53
@@ -64,6 +64,71 @@ PROJ2="$WORK/proj2/Some_App"; mkdir -p "$PROJ2"
|
||||
PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["project_id"])' 2>/dev/null)
|
||||
[[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)"
|
||||
|
||||
echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) ====="
|
||||
EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex"
|
||||
cat > "$EXP/.claude/settings.json" <<'EOF'
|
||||
{ "model": "claude-opus-4-8", "hooks": { "PreToolUse": [ {"matcher":"*","hooks":[{"type":"command","command":"my-existing-hook.sh"}]} ] } }
|
||||
EOF
|
||||
echo "agent" > "$EXP/.claude/agents/reviewer.md"; echo "skill" > "$EXP/.claude/skills/deploy.md"
|
||||
printf '[hooks]\nenabled = true\n\n[mytool]\nfoo = 1\n' > "$EXP/.codex/config.toml"
|
||||
( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 )
|
||||
py_check() { python3 - "$1" "$2" <<'PY'
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
cmds=[h["command"] for ev in d.get("hooks",{}).values() for g in ev for h in g["hooks"]]
|
||||
sys.exit(0 if any(sys.argv[2] in c for c in cmds) else 1)
|
||||
PY
|
||||
}
|
||||
[ -f "$EXP/.claude/agents/reviewer.md" ] && [ -f "$EXP/.claude/skills/deploy.md" ] && pass "existing agents/skills preserved" || fail "agents/skills lost"
|
||||
py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "existing Claude hook preserved (not clobbered)" || fail "existing hook clobbered"
|
||||
py_check "$EXP/.claude/settings.json" "claude_hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged"
|
||||
[ "$(python3 -c 'import json;print(json.load(open("'"$EXP"'/.claude/settings.json")).get("model"))')" = "claude-opus-4-8" ] && pass "unrelated settings key (model) preserved" || fail "model key lost"
|
||||
grep -q '\[mytool\]' "$EXP/.codex/config.toml" && pass "existing codex [mytool] preserved" || fail "mytool lost"
|
||||
[ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]"
|
||||
# idempotent
|
||||
( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 )
|
||||
N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "claude_hook.py" in h["command"]))')
|
||||
[ "$N" = "1" ] && pass "re-running init is idempotent (no duplicate CASAN hook)" || fail "init duplicated CASAN hook (n=$N)"
|
||||
|
||||
echo "===== ⑧ level-aware install + init (packaging/levels.json) ====="
|
||||
# core install: no devkit, casan init unavailable
|
||||
CORE_HOME="$WORK/home-core/.casan"
|
||||
CASAN_HOME="$CORE_HOME" sh "$REPO_ROOT/install.sh" --level core >/dev/null 2>&1
|
||||
[ -d "$CORE_HOME/current/packages/casan-devkit" ] && fail "core install pulled in devkit" || pass "core level excludes devkit"
|
||||
[ "$(cat "$CORE_HOME/current/.casan-level" 2>/dev/null)" = "core" ] && pass "install records level=core" || fail "no .casan-level=core"
|
||||
# Always run init inside a THROWAWAY dir — never the ambient cwd.
|
||||
CINIT="$WORK/core-init-proj"; mkdir -p "$CINIT"
|
||||
CI_OUT=$( ( cd "$CINIT" && "$CORE_HOME/bin/casan" init 2>&1 | head -1 ) || true )
|
||||
echo "$CI_OUT" | grep -q "requires the casan-devkit" && pass "casan init unavailable on core (clear message)" || fail "core init did not fail clearly ($CI_OUT)"
|
||||
|
||||
# enterprise refused, platform refused-with-guidance
|
||||
ERC=0; CASAN_HOME="$WORK/home-e/.casan" sh "$REPO_ROOT/install.sh" --level enterprise >/dev/null 2>&1 || ERC=$?
|
||||
[ "$ERC" -eq 3 ] && pass "install --level enterprise refused (rc=3, no fake-complete)" || fail "enterprise not refused (rc=$ERC)"
|
||||
PRC=0; CASAN_HOME="$WORK/home-p/.casan" sh "$REPO_ROOT/install.sh" --level platform >/dev/null 2>&1 || PRC=$?
|
||||
[ "$PRC" -ne 0 ] && pass "install --level platform refused (preview service, rc=$PRC)" || fail "platform install pretended to succeed"
|
||||
|
||||
# devkit install: init --level differentiation
|
||||
DK_HOME="$WORK/home-dk/.casan"
|
||||
CASAN_HOME="$DK_HOME" sh "$REPO_ROOT/install.sh" --level devkit >/dev/null 2>&1
|
||||
DKC="$DK_HOME/bin/casan"
|
||||
L1="$WORK/l1"; mkdir -p "$L1"; ( cd "$L1" && "$DKC" init --level 1 --project l1 >/dev/null 2>&1 )
|
||||
( [ ! -d "$L1/.gitea" ] && [ ! -d "$L1/apps" ] ) && pass "init --level 1 = gate/hooks only (no CI/domain)" || fail "L1 wrote devkit extras"
|
||||
[ -f "$L1/.claude/settings.json" ] && pass "init --level 1 still writes Plan-20 hooks (core feature)" || fail "L1 missing hooks"
|
||||
L2="$WORK/l2"; mkdir -p "$L2"; ( cd "$L2" && "$DKC" init --level 2 --project l2 >/dev/null 2>&1 )
|
||||
( [ -f "$L2/.gitea/workflows/casan-ci.yml" ] && [ -d "$L2/apps/l2/domain" ] ) && pass "init --level 2 adds CI + domain-pack" || fail "L2 missing devkit extras"
|
||||
L4RC=0; L4="$WORK/l4"; mkdir -p "$L4"; ( cd "$L4" && "$DKC" init --level 4 --project l4 >/dev/null 2>&1 ) || L4RC=$?
|
||||
[ "$L4RC" -eq 3 ] && pass "init --level 4 (enterprise) refused (rc=3)" || fail "L4 init not refused (rc=$L4RC)"
|
||||
LVL=$( ( cd "$L2" && "$DKC" level show ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null)
|
||||
[ "$LVL" = "2" ] && pass "casan level show reports project target level" || fail "level show wrong ($LVL)"
|
||||
|
||||
echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself ====="
|
||||
HUB="$WORK/fakehub"; mkdir -p "$HUB/packages/casan-harness/scripts/bash"
|
||||
echo '#casan' > "$HUB/packages/casan-harness/scripts/bash/casan-harness.sh"
|
||||
GRC=0; ( cd "$HUB" && "$DKC" init --project hub >/dev/null 2>"$WORK/hub.err" ) || GRC=$?
|
||||
[ "$GRC" -eq 65 ] && pass "init refuses a source hub (rc=65)" || fail "source-hub not refused (rc=$GRC)"
|
||||
[ ! -f "$HUB/.claude/settings.json" ] && pass "no hooks written into the refused hub" || fail "hooks written into source hub"
|
||||
( cd "$HUB" && "$DKC" init --project hub --force >/dev/null 2>&1 ) && pass "--force overrides the source-hub guard" || fail "--force did not override guard"
|
||||
|
||||
echo ""
|
||||
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
|
||||
Reference in New Issue
Block a user