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
@@ -42,7 +42,8 @@ casan — CASAN governance harness CLI ($(version))
|
|||||||
Usage: casan <command> [args]
|
Usage: casan <command> [args]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
init [--project id] [--client all] Adopt CASAN into THIS project (config only, hybrid model)
|
init [--level 1..4] [--project id] Adopt CASAN into THIS project (config only, hybrid model)
|
||||||
|
level <show|set 1..4> Show / change the project's packaging level
|
||||||
verify-harness Verify the resolved harness matches the project pin
|
verify-harness Verify the resolved harness matches the project pin
|
||||||
run <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out)
|
run <in> <out> [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out)
|
||||||
gate Run the full CI harness gate (all suites)
|
gate Run the full CI harness gate (all suites)
|
||||||
@@ -73,6 +74,15 @@ case "$cmd" in
|
|||||||
verify-harness)
|
verify-harness)
|
||||||
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: verify-harness requires the casan-devkit package" >&2; exit 1; }
|
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: verify-harness requires the casan-devkit package" >&2; exit 1; }
|
||||||
exec python3 "$DEVKIT_ROOT/casan-init.py" verify "$@" ;;
|
exec python3 "$DEVKIT_ROOT/casan-init.py" verify "$@" ;;
|
||||||
|
level)
|
||||||
|
[[ -f "$DEVKIT_ROOT/casan-init.py" ]] || { echo "casan: level requires the casan-devkit package" >&2; exit 1; }
|
||||||
|
sub="${1:-show}"; shift || true
|
||||||
|
case "$sub" in
|
||||||
|
show) exec python3 "$DEVKIT_ROOT/casan-init.py" level --show "$@" ;;
|
||||||
|
set) n="${1:-devkit}"; shift || true
|
||||||
|
exec python3 "$DEVKIT_ROOT/casan-init.py" init --level "$n" "$@" ;;
|
||||||
|
*) echo "casan: usage: casan level <show|set <1..4>>" >&2; exit 64 ;;
|
||||||
|
esac ;;
|
||||||
run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;;
|
run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;;
|
||||||
gate)
|
gate)
|
||||||
if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then
|
if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then
|
||||||
|
|||||||
@@ -21,12 +21,26 @@ sh install.sh # macOS/Linux
|
|||||||
pwsh .\install.ps1 # Windows
|
pwsh .\install.ps1 # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Chọn **level đóng gói** để cài (theo `packaging/levels.json`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh install.sh --level core # L1: harness + gates + CLI (casan run/gate/verify)
|
||||||
|
sh install.sh --level devkit # L2 (mặc định): + adoption tooling (casan init, CI, domain-pack)
|
||||||
|
sh install.sh --level platform # L3 preview: từ chối — là service, deploy riêng
|
||||||
|
sh install.sh --level enterprise# L4 future: từ chối (chưa ship)
|
||||||
|
```
|
||||||
|
|
||||||
|
Levels là cumulative (devkit ⊃ core). `casan init` là tính năng của **L2 (devkit)**;
|
||||||
|
cài `--level core` sẽ không có `casan init` (báo rõ ràng).
|
||||||
|
|
||||||
Installer sẽ:
|
Installer sẽ:
|
||||||
|
|
||||||
- Copy harness vào `$CASAN_HOME/versions/<version>` (mặc định `~/.casan`,
|
- Copy harness vào `$CASAN_HOME/versions/<version>` (mặc định `~/.casan`,
|
||||||
Windows: `%LOCALAPPDATA%\casan`) và trỏ `current` vào version đó.
|
Windows: `%LOCALAPPDATA%\casan`) và trỏ `current` vào version đó.
|
||||||
- Ghi **hash toàn vẹn** của gate-code (`.harness-hash`) — mỏ neo cho pin+verify.
|
- Ghi **hash toàn vẹn** của gate-code (`.harness-hash`) — mỏ neo cho pin+verify.
|
||||||
- Tạo launcher `casan` và đưa lên PATH (`~/.local/bin` hoặc `$CASAN_HOME/bin`).
|
- Ghi level đã cài vào `.casan-level`.
|
||||||
|
- Tạo launcher `casan` (tự định vị install của chính nó) và đưa lên PATH
|
||||||
|
(`~/.local/bin` hoặc `$CASAN_HOME/bin`).
|
||||||
|
|
||||||
> Windows cần **Git for Windows (Git Bash)** để *chạy* harness (xem
|
> Windows cần **Git for Windows (Git Bash)** để *chạy* harness (xem
|
||||||
> [CASAN_AGENTIC_CLIENTS_WINDOWS.md](CASAN_AGENTIC_CLIENTS_WINDOWS.md)) — không
|
> [CASAN_AGENTIC_CLIENTS_WINDOWS.md](CASAN_AGENTIC_CLIENTS_WINDOWS.md)) — không
|
||||||
@@ -39,11 +53,25 @@ cục bộ), `CASAN_DIST_URL` (tải tarball), `CASAN_NO_PATH_LINK=1` (không t
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd <dự-án-của-bạn>
|
cd <dự-án-của-bạn>
|
||||||
casan init # project id lấy theo tên thư mục
|
casan init # project id lấy theo tên thư mục, level=devkit
|
||||||
# hoặc:
|
# hoặc chọn level áp dụng cho project:
|
||||||
casan init --project my-app --client all --mode observe
|
casan init --level 1 --project my-app # L1: chỉ gate + hook Plan-20
|
||||||
|
casan init --level 2 --project my-app # L2 (mặc định): + CI workflow + domain-pack
|
||||||
|
casan level show # xem level đã cài + level project
|
||||||
|
casan level set 2 # đổi level project (không cần init lại)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Áp dụng cho dự án ĐÃ có vỏ (agents/skills/hook sẵn):** an toàn.
|
||||||
|
- `init` **KHÔNG index/parse code, KHÔNG sửa source, KHÔNG dựng lại vỏ** — chỉ thêm config.
|
||||||
|
- Hook được **MERGE** idempotent vào `.claude/settings.json` / `.codex/hooks.json`
|
||||||
|
hiện có (giữ nguyên hook/agents/skills/khóa khác của bạn), không ghi đè. Chạy
|
||||||
|
`init` nhiều lần không nhân đôi hook.
|
||||||
|
- **Guardrail:** `init` **từ chối** khi target chính là một CASAN source hub (để
|
||||||
|
không tự chặn agent đang phát triển CASAN); dùng `--force` nếu thực sự cần.
|
||||||
|
|
||||||
|
`--level 3` (platform) chỉ áp base L2 + nhắc rằng platform là service deploy riêng;
|
||||||
|
`--level 4` (enterprise) bị từ chối (chưa ship).
|
||||||
|
|
||||||
`casan init` chỉ ghi **config per-project** (không copy harness):
|
`casan init` chỉ ghi **config per-project** (không copy harness):
|
||||||
|
|
||||||
| File | Vai trò |
|
| File | Vai trò |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# CASAN Plan-21 — Hybrid Global Install + `casan init`
|
# CASAN Plan-21 — Hybrid Global Install + `casan init`
|
||||||
|
|
||||||
> Ngày lập: 2026-07-23
|
> Ngày lập: 2026-07-23
|
||||||
> Trạng thái: **IMPLEMENTED (MVP) — global install + `casan init` + pin/verify xanh (21/21 test)**
|
> Trạng thái: **IMPLEMENTED — global install + `casan init` + pin/verify + level-aware + merge-safe (41/41 test)**
|
||||||
> Liên quan: [Plan-20](CASAN_PLAN_20_AGENTIC_CLIENT_INTEGRATION.md) (adapter/hook là base phổ quát), [CASAN_INSTALL_HYBRID.md](../casan/CASAN_INSTALL_HYBRID.md)
|
> Liên quan: [Plan-20](CASAN_PLAN_20_AGENTIC_CLIENT_INTEGRATION.md) (adapter/hook là base phổ quát), [CASAN_INSTALL_HYBRID.md](../casan/CASAN_INSTALL_HYBRID.md)
|
||||||
|
|
||||||
## 1. Mục tiêu
|
## 1. Mục tiêu
|
||||||
@@ -42,9 +42,23 @@ harness global thao tác đúng trên state của dự án hiện tại.
|
|||||||
| Integrity hash primitive | `packages/casan-harness/scripts/python/harness_hash.py` |
|
| Integrity hash primitive | `packages/casan-harness/scripts/python/harness_hash.py` |
|
||||||
| `casan init` / `verify` | `packages/casan-devkit/casan-init.py` |
|
| `casan init` / `verify` | `packages/casan-devkit/casan-init.py` |
|
||||||
| CLI wiring | `bin/casan` (`init`, `verify-harness`) |
|
| CLI wiring | `bin/casan` (`init`, `verify-harness`) |
|
||||||
| Acceptance suite | `packages/casan-devkit/tests/hybrid-install-tests.sh` — **21/21 PASS** |
|
| Acceptance suite | `packages/casan-devkit/tests/hybrid-install-tests.sh` — **41/41 PASS** |
|
||||||
| Adoption doc | `docs/casan/CASAN_INSTALL_HYBRID.md` |
|
| Adoption doc | `docs/casan/CASAN_INSTALL_HYBRID.md` |
|
||||||
|
|
||||||
|
### Level selection (4 packaging levels) & safe adoption
|
||||||
|
|
||||||
|
- `install.sh --level core|devkit` (driven by `packaging/levels.json`); `platform`
|
||||||
|
refused as a preview SERVICE, `enterprise` refused (future). Level recorded in
|
||||||
|
`.casan-level`; launcher self-locates its own install (no cross-home talk).
|
||||||
|
- `casan init --level 1..4`: L1 = gate + Plan-20 hooks only; L2 = + CI workflow +
|
||||||
|
domain-pack; L3 = L2 base + preview note; L4 = refused. `casan level show`/`set`.
|
||||||
|
- **Merge, not clobber:** `init` merges Plan-20 hooks into an existing
|
||||||
|
`.claude/settings.json` / `.codex/{hooks.json,config.toml}` idempotently,
|
||||||
|
preserving the project's own hooks/agents/skills. Never re-indexes or rewrites
|
||||||
|
the project shell.
|
||||||
|
- **Source-hub guardrail:** `init` refuses to adopt a CASAN source hub into itself
|
||||||
|
(would block the developing agent); `--force` overrides.
|
||||||
|
|
||||||
## 5. Definition of Done
|
## 5. Definition of Done
|
||||||
|
|
||||||
- ✅ `install.sh` cài harness + launcher + integrity hash từ checkout cục bộ.
|
- ✅ `install.sh` cài harness + launcher + integrity hash từ checkout cục bộ.
|
||||||
|
|||||||
+6
-1
@@ -92,7 +92,12 @@ $binDir = Join-Path $CasanHome 'bin'
|
|||||||
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
|
||||||
$launcherPs1 = @'
|
$launcherPs1 = @'
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$CasanHome = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA "casan" }
|
# Self-locate THIS install from the launcher's own path (<home>\bin\casan.ps1) so a
|
||||||
|
# specific launcher always uses ITS install regardless of any ambient CASAN_HOME.
|
||||||
|
$selfHome = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||||
|
if ((Test-Path (Join-Path $selfHome "current")) -or (Test-Path (Join-Path $selfHome "versions"))) {
|
||||||
|
$CasanHome = $selfHome
|
||||||
|
} elseif ($env:CASAN_HOME) { $CasanHome = $env:CASAN_HOME } else { $CasanHome = Join-Path $env:LOCALAPPDATA "casan" }
|
||||||
$cur = Join-Path $CasanHome "current"
|
$cur = Join-Path $CasanHome "current"
|
||||||
if (-not (Test-Path $cur)) { Write-Error "casan: no install at $cur (run install.ps1)"; exit 1 }
|
if (-not (Test-Path $cur)) { Write-Error "casan: no install at $cur (run install.ps1)"; exit 1 }
|
||||||
$env:CASAN_HARNESS_ROOT = Join-Path $cur "packages\casan-harness"
|
$env:CASAN_HARNESS_ROOT = Join-Path $cur "packages\casan-harness"
|
||||||
|
|||||||
+48
-3
@@ -15,12 +15,34 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
CASAN_HOME="${CASAN_HOME:-$HOME/.casan}"
|
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"; }
|
log() { printf '[casan-install] %s\n' "$1"; }
|
||||||
die() { printf '[casan-install] ERROR: %s\n' "$1" >&2; exit 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."
|
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)
|
# ── 1) Locate the source (local checkout, explicit CASAN_SRC, or a dist tarball)
|
||||||
SRC=""
|
SRC=""
|
||||||
CLEANUP=""
|
CLEANUP=""
|
||||||
@@ -50,6 +72,7 @@ fi
|
|||||||
VERSION="$(cat "$SRC/VERSION" 2>/dev/null || echo "0.0.0")"
|
VERSION="$(cat "$SRC/VERSION" 2>/dev/null || echo "0.0.0")"
|
||||||
log "source : $SRC"
|
log "source : $SRC"
|
||||||
log "version : $VERSION"
|
log "version : $VERSION"
|
||||||
|
log "level : $LEVEL (core=harness+CLI, devkit=+adoption tooling)"
|
||||||
log "install : $CASAN_HOME"
|
log "install : $CASAN_HOME"
|
||||||
|
|
||||||
# ── 2) Copy harness + devkit + CLI into a versioned dir ──────────────────────
|
# ── 2) Copy harness + devkit + CLI into a versioned dir ──────────────────────
|
||||||
@@ -67,11 +90,21 @@ copy_tree() { # <relpath>
|
|||||||
cp -R "$SRC/$1/." "$DEST/$1/"
|
cp -R "$SRC/$1/." "$DEST/$1/"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
# L1 core: harness + CLI + installer + level manifest.
|
||||||
copy_tree "packages/casan-harness"
|
copy_tree "packages/casan-harness"
|
||||||
copy_tree "packages/casan-devkit"
|
|
||||||
cp "$SRC/bin/casan" "$DEST/bin/casan"
|
cp "$SRC/bin/casan" "$DEST/bin/casan"
|
||||||
chmod +x "$DEST/bin/casan"
|
chmod +x "$DEST/bin/casan"
|
||||||
cp "$SRC/VERSION" "$DEST/VERSION" 2>/dev/null || printf '%s\n' "$VERSION" > "$DEST/VERSION"
|
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.
|
# 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 '__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
|
#!/usr/bin/env bash
|
||||||
# CASAN global launcher — resolves the shared harness and the CURRENT project.
|
# CASAN global launcher — resolves the shared harness and the CURRENT project.
|
||||||
set -uo pipefail
|
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"
|
CUR="$CASAN_HOME/current"
|
||||||
[[ -d "$CUR" ]] || { echo "casan: no install at $CUR (run install.sh)" >&2; exit 1; }
|
[[ -d "$CUR" ]] || { echo "casan: no install at $CUR (run install.sh)" >&2; exit 1; }
|
||||||
export CASAN_HARNESS_ROOT="$CUR/packages/casan-harness"
|
export CASAN_HARNESS_ROOT="$CUR/packages/casan-harness"
|
||||||
@@ -127,6 +168,10 @@ else
|
|||||||
log "launcher : $CASAN_HOME/bin/casan"
|
log "launcher : $CASAN_HOME/bin/casan"
|
||||||
log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)"
|
log "add to PATH: export PATH=\"$CASAN_HOME/bin:\$PATH\" (add to your shell rc)"
|
||||||
fi
|
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
|
[ -n "$CLEANUP" ] && rm -rf "$CLEANUP" || true
|
||||||
|
|||||||
@@ -34,11 +34,47 @@ import time
|
|||||||
|
|
||||||
PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||||
|
|
||||||
|
# Packaging levels (docs/packaging/CASAN_PACKAGING_PLAN.md, packaging/levels.json).
|
||||||
|
# Cumulative: devkit⊃core, platform⊃devkit, enterprise⊃platform.
|
||||||
|
LEVEL_NAME = {"1": "core", "2": "devkit", "3": "platform", "4": "enterprise",
|
||||||
|
"core": "core", "devkit": "devkit", "platform": "platform", "enterprise": "enterprise"}
|
||||||
|
LEVEL_NUM = {"core": 1, "devkit": 2, "platform": 3, "enterprise": 4}
|
||||||
|
|
||||||
|
|
||||||
def now_iso():
|
def now_iso():
|
||||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def devkit_root():
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_if_absent(src, dst, created_rel, target, created):
|
||||||
|
if not os.path.exists(src) or os.path.exists(dst):
|
||||||
|
return False
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
with open(src, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
with open(dst, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
created.append(os.path.relpath(dst, target))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_tree_missing(src_dir, dst_dir, target, created):
|
||||||
|
if not os.path.isdir(src_dir):
|
||||||
|
return 0
|
||||||
|
n = 0
|
||||||
|
for dp, _dn, fns in os.walk(src_dir):
|
||||||
|
rel = os.path.relpath(dp, src_dir)
|
||||||
|
for fn in fns:
|
||||||
|
s = os.path.join(dp, fn)
|
||||||
|
d = os.path.join(dst_dir, rel, fn) if rel != "." else os.path.join(dst_dir, fn)
|
||||||
|
if _copy_if_absent(s, d, None, target, created):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def resolve_harness(explicit):
|
def resolve_harness(explicit):
|
||||||
for cand in (explicit, os.environ.get("CASAN_HARNESS_ROOT")):
|
for cand in (explicit, os.environ.get("CASAN_HARNESS_ROOT")):
|
||||||
if cand and os.path.isdir(os.path.join(cand, "scripts", "bash")):
|
if cand and os.path.isdir(os.path.join(cand, "scripts", "bash")):
|
||||||
@@ -121,6 +157,101 @@ def _copy_template(src, dst, backups):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_or(path, default):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_once(path, backups):
|
||||||
|
bak = path + ".casan-bak"
|
||||||
|
if os.path.exists(path) and not os.path.exists(bak):
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
old = fh.read()
|
||||||
|
with open(bak, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(old)
|
||||||
|
backups.append(bak)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_marker_hook(groups, marker):
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
return False
|
||||||
|
for g in groups:
|
||||||
|
if not isinstance(g, dict):
|
||||||
|
continue
|
||||||
|
for h in g.get("hooks", []) or []:
|
||||||
|
if isinstance(h, dict) and marker in str(h.get("command", "")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def merge_json_hooks(target_file, template_file, marker, backups):
|
||||||
|
"""MERGE the template's hook groups into an existing hooks JSON without
|
||||||
|
clobbering the user's own hooks/agents/skills config. Idempotent by `marker`:
|
||||||
|
re-running never duplicates the CASAN hook. Returns created|merged|unchanged|None."""
|
||||||
|
tmpl = _load_json_or(template_file, None)
|
||||||
|
if not isinstance(tmpl, dict):
|
||||||
|
return None
|
||||||
|
existed = os.path.exists(target_file)
|
||||||
|
doc = _load_json_or(target_file, {}) if existed else {}
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
doc = {}
|
||||||
|
hooks = doc.get("hooks")
|
||||||
|
if not isinstance(hooks, dict):
|
||||||
|
hooks = {}
|
||||||
|
doc["hooks"] = hooks
|
||||||
|
changed = not existed
|
||||||
|
for event, groups in (tmpl.get("hooks") or {}).items():
|
||||||
|
cur = hooks.get(event)
|
||||||
|
if not isinstance(cur, list):
|
||||||
|
cur = []
|
||||||
|
hooks[event] = cur
|
||||||
|
if _has_marker_hook(cur, marker):
|
||||||
|
continue
|
||||||
|
cur.extend(groups if isinstance(groups, list) else [])
|
||||||
|
changed = True
|
||||||
|
for k, v in tmpl.items(): # carry template scalars (e.g. "version") if absent
|
||||||
|
if k != "hooks" and k not in doc:
|
||||||
|
doc[k] = v
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
_backup_once(target_file, backups)
|
||||||
|
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||||
|
with open(target_file, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
return ("created" if not existed else "merged") if changed else "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_codex_config(target_file, mode, integration_mode, backups):
|
||||||
|
"""Append a CASAN [casan] section (and, only if absent, a [hooks] enabler) to
|
||||||
|
an existing .codex/config.toml without duplicating tables. stdlib-only; no TOML
|
||||||
|
writer needed. Idempotent by the '[casan]' marker."""
|
||||||
|
casan_block = (
|
||||||
|
"\n# CASAN Plan-20 (added by casan init)\n"
|
||||||
|
"[casan]\n"
|
||||||
|
'enforcement_mode = "%s"\n'
|
||||||
|
'integration_mode = "%s"\n' % (mode, integration_mode)
|
||||||
|
)
|
||||||
|
hooks_block = "\n[hooks]\nenabled = true\nproject_hooks = true\n"
|
||||||
|
if not os.path.exists(target_file):
|
||||||
|
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||||
|
with open(target_file, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("# CASAN Plan-20 Codex config (created by casan init)\n"
|
||||||
|
+ hooks_block + casan_block)
|
||||||
|
return "created"
|
||||||
|
with open(target_file, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
cur = fh.read()
|
||||||
|
if "[casan]" in cur:
|
||||||
|
return "unchanged"
|
||||||
|
_backup_once(target_file, backups)
|
||||||
|
add = ("" if "[hooks]" in cur else hooks_block) + casan_block
|
||||||
|
with open(target_file, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(cur.rstrip() + "\n" + add)
|
||||||
|
return "merged"
|
||||||
|
|
||||||
|
|
||||||
def cmd_init(args):
|
def cmd_init(args):
|
||||||
harness = resolve_harness(args.harness)
|
harness = resolve_harness(args.harness)
|
||||||
if not harness:
|
if not harness:
|
||||||
@@ -132,11 +263,37 @@ def cmd_init(args):
|
|||||||
sys.stderr.write("casan init: target is not a directory: %s\n" % target)
|
sys.stderr.write("casan init: target is not a directory: %s\n" % target)
|
||||||
return 66
|
return 66
|
||||||
|
|
||||||
|
# Guardrail: refuse to adopt a CASAN SOURCE HUB into itself. Installing the
|
||||||
|
# Plan-20 PreToolUse hook into the CASAN repo would block the very agent
|
||||||
|
# developing CASAN (no admission => deny). Detect the hub by the harness
|
||||||
|
# source living inside the target. Override with --force for the rare
|
||||||
|
# intentional case.
|
||||||
|
hub_marker = os.path.join(target, "packages", "casan-harness", "scripts", "bash", "casan-harness.sh")
|
||||||
|
if os.path.exists(hub_marker) and not args.force:
|
||||||
|
sys.stderr.write(
|
||||||
|
"casan init: target looks like a CASAN SOURCE HUB (%s exists) — refusing to "
|
||||||
|
"adopt CASAN into itself (the Plan-20 hooks would block your own agent). "
|
||||||
|
"Use --force only if you really mean to.\n" % os.path.relpath(hub_marker, target))
|
||||||
|
return 65
|
||||||
|
|
||||||
project = args.project or re.sub(r"[^a-z0-9-]", "-", os.path.basename(target).lower()).strip("-")
|
project = args.project or re.sub(r"[^a-z0-9-]", "-", os.path.basename(target).lower()).strip("-")
|
||||||
if not PROJECT_RE.match(project):
|
if not PROJECT_RE.match(project):
|
||||||
sys.stderr.write("casan init: --project must match ^[a-z][a-z0-9-]{1,62}$ (got %r)\n" % project)
|
sys.stderr.write("casan init: --project must match ^[a-z][a-z0-9-]{1,62}$ (got %r)\n" % project)
|
||||||
return 64
|
return 64
|
||||||
|
|
||||||
|
# Packaging level to adopt into the project.
|
||||||
|
lvl_name = LEVEL_NAME.get(str(args.level).lower())
|
||||||
|
if not lvl_name:
|
||||||
|
sys.stderr.write("casan init: --level must be 1..4 or core|devkit|platform|enterprise\n")
|
||||||
|
return 64
|
||||||
|
lvl = LEVEL_NUM[lvl_name]
|
||||||
|
if lvl == 4:
|
||||||
|
sys.stderr.write("casan init: Level 4 (enterprise) is FUTURE / not shipped — refusing "
|
||||||
|
"(no fake-complete adoption). See packaging/levels.json.\n")
|
||||||
|
return 3
|
||||||
|
preview = (lvl == 3) # platform is a separate preview SERVICE; init applies the L2 base
|
||||||
|
apply_devkit = (lvl >= 2)
|
||||||
|
|
||||||
clients = ["claude", "codex"] if args.client == "all" else [args.client]
|
clients = ["claude", "codex"] if args.client == "all" else [args.client]
|
||||||
version = harness_version(harness)
|
version = harness_version(harness)
|
||||||
hhash, hsource = compute_harness_hash(harness)
|
hhash, hsource = compute_harness_hash(harness)
|
||||||
@@ -157,6 +314,8 @@ def cmd_init(args):
|
|||||||
"clients": clients,
|
"clients": clients,
|
||||||
"harness_version": version,
|
"harness_version": version,
|
||||||
"adoption_model": "hybrid-global",
|
"adoption_model": "hybrid-global",
|
||||||
|
"target_level": lvl,
|
||||||
|
"target_level_name": lvl_name,
|
||||||
}
|
}
|
||||||
p = os.path.join(cfg_dir, "config.json")
|
p = os.path.join(cfg_dir, "config.json")
|
||||||
_write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
|
_write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
|
||||||
@@ -192,18 +351,46 @@ def cmd_init(args):
|
|||||||
if not os.path.exists(gi):
|
if not os.path.exists(gi):
|
||||||
_write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi)
|
_write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi)
|
||||||
|
|
||||||
# ── Plan-20 client hooks from the harness adapters ──
|
# ── Plan-20 client hooks — MERGED into any existing config, never clobbered.
|
||||||
|
# A project that already has a shell (.claude/agents, skills, its own hooks)
|
||||||
|
# keeps everything; CASAN hooks are added idempotently.
|
||||||
ad = os.path.join(harness, "adapters")
|
ad = os.path.join(harness, "adapters")
|
||||||
|
merges = {}
|
||||||
if "claude" in clients:
|
if "claude" in clients:
|
||||||
if _copy_template(os.path.join(ad, "claude-code", "settings.template.json"),
|
dst = os.path.join(target, ".claude", "settings.json")
|
||||||
os.path.join(target, ".claude", "settings.json"), backups):
|
r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"),
|
||||||
created_add(os.path.join(target, ".claude", "settings.json"))
|
"claude_hook.py", backups)
|
||||||
|
if r and r != "unchanged":
|
||||||
|
created_add(dst)
|
||||||
|
merges[".claude/settings.json"] = r
|
||||||
if "codex" in clients:
|
if "codex" in clients:
|
||||||
for src, dst in (("hooks.template.json", "hooks.json"),
|
dsth = os.path.join(target, ".codex", "hooks.json")
|
||||||
("config.template.toml", "config.toml")):
|
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"),
|
||||||
if _copy_template(os.path.join(ad, "codex", src),
|
"codex_hook.py", backups)
|
||||||
os.path.join(target, ".codex", dst), backups):
|
if r and r != "unchanged":
|
||||||
created_add(os.path.join(target, ".codex", dst))
|
created_add(dsth)
|
||||||
|
merges[".codex/hooks.json"] = r
|
||||||
|
dstc = os.path.join(target, ".codex", "config.toml")
|
||||||
|
r = merge_codex_config(dstc, args.mode, args.integration_mode, backups)
|
||||||
|
if r and r != "unchanged":
|
||||||
|
created_add(dstc)
|
||||||
|
merges[".codex/config.toml"] = r
|
||||||
|
|
||||||
|
# ── Level 2 (devkit) adoption extras: CI workflow + domain-pack scaffold.
|
||||||
|
# These are the real difference between L1 (gate/hooks only) and L2 (full
|
||||||
|
# adoption). Copied only if absent — never clobber the project's own files.
|
||||||
|
level_extras = []
|
||||||
|
if apply_devkit:
|
||||||
|
dk = devkit_root()
|
||||||
|
ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml")
|
||||||
|
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
|
||||||
|
if _copy_if_absent(ci_src, ci_dst, None, target, created):
|
||||||
|
level_extras.append(".gitea/workflows/casan-ci.yml")
|
||||||
|
dom_src = os.path.join(dk, "templates", "domain-pack")
|
||||||
|
dom_dst = os.path.join(target, "apps", project, "domain")
|
||||||
|
n = _copy_tree_missing(dom_src, dom_dst, target, created)
|
||||||
|
if n:
|
||||||
|
level_extras.append("apps/%s/domain (%d files)" % (project, n))
|
||||||
|
|
||||||
# ── manifest (so uninstall/verify know what init created) ──
|
# ── manifest (so uninstall/verify know what init created) ──
|
||||||
manifest = {
|
manifest = {
|
||||||
@@ -219,14 +406,22 @@ def cmd_init(args):
|
|||||||
"status": "initialized",
|
"status": "initialized",
|
||||||
"project_id": project,
|
"project_id": project,
|
||||||
"target": target,
|
"target": target,
|
||||||
|
"target_level": lvl,
|
||||||
|
"target_level_name": lvl_name,
|
||||||
"harness_version": version,
|
"harness_version": version,
|
||||||
"harness_hash": hhash,
|
"harness_hash": hhash,
|
||||||
"enforcement_mode": args.mode,
|
"enforcement_mode": args.mode,
|
||||||
"clients": clients,
|
"clients": clients,
|
||||||
"created": created,
|
"created": created,
|
||||||
"note": ("harness NOT copied into repo (hybrid model); "
|
"hook_merges": merges,
|
||||||
"run `casan verify-harness` to check the pin"),
|
"level_extras": level_extras,
|
||||||
|
"note": ("harness NOT copied into repo (hybrid model); client hooks MERGED "
|
||||||
|
"(existing config preserved); run `casan verify-harness` to check the pin"),
|
||||||
}, ensure_ascii=False, indent=2))
|
}, ensure_ascii=False, indent=2))
|
||||||
|
if preview:
|
||||||
|
sys.stderr.write("casan init: NOTE — Level 3 (platform) is a PREVIEW SERVICE (Control "
|
||||||
|
"Panel/Dashboard), adopted by DEPLOYING it, not by repo config. "
|
||||||
|
"Applied the Level 2 base here; deploy platform separately.\n")
|
||||||
if hsource == "error":
|
if hsource == "error":
|
||||||
sys.stderr.write("casan init: WARNING — could not compute harness hash; "
|
sys.stderr.write("casan init: WARNING — could not compute harness hash; "
|
||||||
"pin verification will be unavailable.\n")
|
"pin verification will be unavailable.\n")
|
||||||
@@ -264,6 +459,36 @@ def cmd_verify(args):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_level(args):
|
||||||
|
"""Show the installed packaging level and the project's target level."""
|
||||||
|
harness = resolve_harness(args.harness)
|
||||||
|
installed = None
|
||||||
|
if harness:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(install_root(harness), ".casan-level"), "r", encoding="utf-8") as fh:
|
||||||
|
installed = fh.read().strip()
|
||||||
|
except (OSError, IOError):
|
||||||
|
installed = "unknown"
|
||||||
|
target = os.path.abspath(args.target or os.getcwd())
|
||||||
|
cfg = _load_json_or(os.path.join(target, ".casan", "config.json"), {})
|
||||||
|
status_map = {1: "implemented", 2: "implemented", 3: "preview", 4: "future"}
|
||||||
|
tl = cfg.get("target_level")
|
||||||
|
out = {
|
||||||
|
"installed_level": installed,
|
||||||
|
"project_target_level": tl,
|
||||||
|
"project_target_level_name": cfg.get("target_level_name"),
|
||||||
|
"project_level_status": status_map.get(tl, "unknown") if tl else None,
|
||||||
|
"levels": {
|
||||||
|
"1 core": "implemented — harness + gates + CLI",
|
||||||
|
"2 devkit": "implemented — + adoption tooling (casan init, CI, domain-pack)",
|
||||||
|
"3 platform": "preview — Control Panel/Dashboard SERVICE (deploy separately)",
|
||||||
|
"4 enterprise": "future — not shipped",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
|
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
|
||||||
sub = parser.add_subparsers(dest="cmd")
|
sub = parser.add_subparsers(dest="cmd")
|
||||||
@@ -272,20 +497,31 @@ def main(argv=None):
|
|||||||
pi.add_argument("--target", help="project root (default: cwd)")
|
pi.add_argument("--target", help="project root (default: cwd)")
|
||||||
pi.add_argument("--project", help="project id (^[a-z][a-z0-9-]{1,62}$; default: dir name)")
|
pi.add_argument("--project", help="project id (^[a-z][a-z0-9-]{1,62}$; default: dir name)")
|
||||||
pi.add_argument("--client", choices=["claude", "codex", "all"], default="all")
|
pi.add_argument("--client", choices=["claude", "codex", "all"], default="all")
|
||||||
|
pi.add_argument("--level", default="devkit",
|
||||||
|
help="packaging level to adopt: 1|core, 2|devkit (default), 3|platform (preview), 4|enterprise (refused)")
|
||||||
pi.add_argument("--mode", choices=["observe", "enforce"], default="observe")
|
pi.add_argument("--mode", choices=["observe", "enforce"], default="observe")
|
||||||
pi.add_argument("--integration-mode", dest="integration_mode",
|
pi.add_argument("--integration-mode", dest="integration_mode",
|
||||||
choices=["project_hook", "managed_hook", "casan_owned"], default="project_hook")
|
choices=["project_hook", "managed_hook", "casan_owned"], default="project_hook")
|
||||||
|
pi.add_argument("--force", action="store_true",
|
||||||
|
help="bypass the source-hub safety guard (adopt CASAN into a CASAN checkout)")
|
||||||
pi.add_argument("--harness", help="override harness root")
|
pi.add_argument("--harness", help="override harness root")
|
||||||
|
|
||||||
pv = sub.add_parser("verify", help="verify the resolved harness matches the project pin")
|
pv = sub.add_parser("verify", help="verify the resolved harness matches the project pin")
|
||||||
pv.add_argument("--target", help="project root (default: cwd)")
|
pv.add_argument("--target", help="project root (default: cwd)")
|
||||||
pv.add_argument("--harness", help="override harness root")
|
pv.add_argument("--harness", help="override harness root")
|
||||||
|
|
||||||
|
pl = sub.add_parser("level", help="show installed + project packaging level")
|
||||||
|
pl.add_argument("--show", action="store_true", help="(default) show levels")
|
||||||
|
pl.add_argument("--target", help="project root (default: cwd)")
|
||||||
|
pl.add_argument("--harness", help="override harness root")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.cmd == "init":
|
if args.cmd == "init":
|
||||||
return cmd_init(args)
|
return cmd_init(args)
|
||||||
if args.cmd == "verify":
|
if args.cmd == "verify":
|
||||||
return cmd_verify(args)
|
return cmd_verify(args)
|
||||||
|
if args.cmd == "level":
|
||||||
|
return cmd_level(args)
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return 64
|
return 64
|
||||||
|
|
||||||
|
|||||||
@@ -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=$(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)"
|
[[ "$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 ""
|
||||||
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||||
|
|||||||
@@ -19,9 +19,14 @@
|
|||||||
"packages/casan-harness/memory",
|
"packages/casan-harness/memory",
|
||||||
"packages/casan-harness/templates",
|
"packages/casan-harness/templates",
|
||||||
"packages/casan-harness/tests",
|
"packages/casan-harness/tests",
|
||||||
|
"packages/casan-harness/adapters",
|
||||||
|
"packages/casan-harness/schemas",
|
||||||
"packages/casan-harness/init-options.json",
|
"packages/casan-harness/init-options.json",
|
||||||
"packages/casan-harness/README.md",
|
"packages/casan-harness/README.md",
|
||||||
"bin/casan",
|
"bin/casan",
|
||||||
|
"install.sh",
|
||||||
|
"install.ps1",
|
||||||
|
"packaging/levels.json",
|
||||||
"VERSION"
|
"VERSION"
|
||||||
],
|
],
|
||||||
"excludes_globs": ["**/__pycache__/**", "**/*.pyc", "**/.DS_Store"],
|
"excludes_globs": ["**/__pycache__/**", "**/*.pyc", "**/.DS_Store"],
|
||||||
|
|||||||
Reference in New Issue
Block a user