From ff4e9d5a5338fb7d3bde684e709a7fc2d1ee6d6c Mon Sep 17 00:00:00 2001 From: thanhnv Date: Thu, 23 Jul 2026 22:00:46 +0700 Subject: [PATCH] feat(install): level-aware casan init + merge-safe adoption + hub guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/casan | 12 +- docs/casan/CASAN_INSTALL_HYBRID.md | 36 ++- docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md | 18 +- install.ps1 | 7 +- install.sh | 51 +++- packages/casan-devkit/casan-init.py | 258 +++++++++++++++++- .../tests/hybrid-install-tests.sh | 65 +++++ packaging/levels.json | 5 + 8 files changed, 430 insertions(+), 22 deletions(-) diff --git a/bin/casan b/bin/casan index 2d9ff3c..7d4fefb 100755 --- a/bin/casan +++ b/bin/casan @@ -42,7 +42,8 @@ casan — CASAN governance harness CLI ($(version)) Usage: casan [args] 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 / change the project's packaging level verify-harness Verify the resolved harness matches the project pin run [action] [-- cmd...] Run a step through the harness (H4→H5→H6→exec→H4-out) gate Run the full CI harness gate (all suites) @@ -73,6 +74,15 @@ case "$cmd" in verify-harness) [[ -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 "$@" ;; + 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 >" >&2; exit 64 ;; + esac ;; run) exec bash "$BASH_DIR/casan-harness.sh" "$@" ;; gate) if [[ -n "${CASAN_PROJECT_MANIFEST:-}${CASAN_PROJECT_ID:-}" ]]; then diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md index 656f49b..619af4f 100644 --- a/docs/casan/CASAN_INSTALL_HYBRID.md +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -21,12 +21,26 @@ sh install.sh # macOS/Linux 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ẽ: - Copy harness vào `$CASAN_HOME/versions/` (mặc định `~/.casan`, 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. -- 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 > [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 cd -casan init # project id lấy theo tên thư mục -# hoặc: -casan init --project my-app --client all --mode observe +casan init # project id lấy theo tên thư mục, level=devkit +# hoặc chọn level áp dụng cho project: +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): | File | Vai trò | diff --git a/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md index 32772b1..6a15c6c 100644 --- a/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md +++ b/docs/plans/CASAN_PLAN_21_HYBRID_INSTALL.md @@ -1,7 +1,7 @@ # CASAN Plan-21 — Hybrid Global Install + `casan init` > 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) ## 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` | | `casan init` / `verify` | `packages/casan-devkit/casan-init.py` | | 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` | +### 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 - ✅ `install.sh` cài harness + launcher + integrity hash từ checkout cục bộ. diff --git a/install.ps1 b/install.ps1 index 441d443..d333ba2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -92,7 +92,12 @@ $binDir = Join-Path $CasanHome 'bin' New-Item -ItemType Directory -Force -Path $binDir | Out-Null $launcherPs1 = @' $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 (\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" 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" diff --git a/install.sh b/install.sh index ae54d0a..becbca7 100755 --- a/install.sh +++ b/install.sh @@ -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() { # 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 (…//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 && casan init" +if [ "$LEVEL" = "devkit" ]; then + log "next: cd && 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 diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index e34edec..b2a575a 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -34,11 +34,47 @@ import time 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(): 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): for cand in (explicit, os.environ.get("CASAN_HARNESS_ROOT")): if cand and os.path.isdir(os.path.join(cand, "scripts", "bash")): @@ -121,6 +157,101 @@ def _copy_template(src, dst, backups): 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): harness = resolve_harness(args.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) 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("-") 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) 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] version = harness_version(harness) hhash, hsource = compute_harness_hash(harness) @@ -157,6 +314,8 @@ def cmd_init(args): "clients": clients, "harness_version": version, "adoption_model": "hybrid-global", + "target_level": lvl, + "target_level_name": lvl_name, } p = os.path.join(cfg_dir, "config.json") _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): _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") + merges = {} if "claude" in clients: - if _copy_template(os.path.join(ad, "claude-code", "settings.template.json"), - os.path.join(target, ".claude", "settings.json"), backups): - created_add(os.path.join(target, ".claude", "settings.json")) + dst = os.path.join(target, ".claude", "settings.json") + r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"), + "claude_hook.py", backups) + if r and r != "unchanged": + created_add(dst) + merges[".claude/settings.json"] = r if "codex" in clients: - for src, dst in (("hooks.template.json", "hooks.json"), - ("config.template.toml", "config.toml")): - if _copy_template(os.path.join(ad, "codex", src), - os.path.join(target, ".codex", dst), backups): - created_add(os.path.join(target, ".codex", dst)) + dsth = os.path.join(target, ".codex", "hooks.json") + r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"), + "codex_hook.py", backups) + if r and r != "unchanged": + 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 = { @@ -219,14 +406,22 @@ def cmd_init(args): "status": "initialized", "project_id": project, "target": target, + "target_level": lvl, + "target_level_name": lvl_name, "harness_version": version, "harness_hash": hhash, "enforcement_mode": args.mode, "clients": clients, "created": created, - "note": ("harness NOT copied into repo (hybrid model); " - "run `casan verify-harness` to check the pin"), + "hook_merges": merges, + "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)) + 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": sys.stderr.write("casan init: WARNING — could not compute harness hash; " "pin verification will be unavailable.\n") @@ -264,6 +459,36 @@ def cmd_verify(args): 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): parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption") 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("--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("--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("--integration-mode", dest="integration_mode", 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") 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("--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) if args.cmd == "init": return cmd_init(args) if args.cmd == "verify": return cmd_verify(args) + if args.cmd == "level": + return cmd_level(args) parser.print_help() return 64 diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 92072e0..ee12410 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -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 diff --git a/packaging/levels.json b/packaging/levels.json index 0888551..eff7128 100644 --- a/packaging/levels.json +++ b/packaging/levels.json @@ -19,9 +19,14 @@ "packages/casan-harness/memory", "packages/casan-harness/templates", "packages/casan-harness/tests", + "packages/casan-harness/adapters", + "packages/casan-harness/schemas", "packages/casan-harness/init-options.json", "packages/casan-harness/README.md", "bin/casan", + "install.sh", + "install.ps1", + "packaging/levels.json", "VERSION" ], "excludes_globs": ["**/__pycache__/**", "**/*.pyc", "**/.DS_Store"],