Files
CASAN/packages/casan-devkit/casan-init.py
T

954 lines
39 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`casan init` / `casan verify-harness` (Plan-21 hybrid adoption).
Adopt CASAN into an EXISTING project by writing only per-project config — the
shared harness stays under $CASAN_HOME and is NOT copied into the repo. This is
the codegraph-style flow: global install once, then `casan init` per project.
What init writes into the target repo:
.casan/config.json project id, enforcement/integration mode, clients
.casan/version.lock pinned harness version + gate-code integrity hash
.casan/agentic.env Plan-20 bridge feature flags
.specify/ runtime state root marker (logs/traces/admissions)
.claude/settings.json Plan-20 Claude Code hooks (--client claude|all)
.codex/hooks.json+config Plan-20 Codex hooks (--client codex|all)
`verify` recomputes the resolved harness gate-code hash and compares it to
version.lock — the pin+VERIFY half. Drift/tamper of the global harness relative
to what the project pinned is caught here (preserves the Plan-16 "gates are
trusted code" guarantee even though the harness lives outside the repo).
stdlib-only. Resolves the harness via CASAN_HARNESS_ROOT (set by the global
launcher) or --harness.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
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}
SUPPORTED_CLIENTS = ("claude", "codex", "vscode-copilot")
VSCODE_EXTENSION_IDS = {
"claude": ("anthropic.claude-code",),
"codex": ("OpenAI.chatgpt",),
"vscode-copilot": (
"GitHub.copilot",
"GitHub.copilot-chat",
"fpt-casan.casan-governed-chat",
),
}
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")):
return os.path.abspath(cand)
install = os.environ.get("CASAN_INSTALL_ROOT")
if install:
c = os.path.join(install, "packages", "casan-harness")
if os.path.isdir(c):
return os.path.abspath(c)
# Fallback: this file lives at packages/casan-devkit/casan-init.py.
c = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "casan-harness"))
return c if os.path.isdir(c) else None
def install_root(harness):
if os.environ.get("CASAN_INSTALL_ROOT"):
return os.path.abspath(os.environ["CASAN_INSTALL_ROOT"])
return os.path.abspath(os.path.join(harness, "..", ".."))
def harness_version(harness):
for p in (os.path.join(install_root(harness), "VERSION"),
os.path.join(harness, "..", "..", "VERSION")):
try:
with open(p, "r", encoding="utf-8") as fh:
v = fh.read().strip()
if v:
return v
except (OSError, IOError):
continue
return "0.0.0"
def compute_live(harness):
"""ALWAYS recompute the gate-code hash from the actual files on disk. Used by
verify so a tampered harness cannot hide behind a stale recorded hash."""
sys.path.insert(0, os.path.join(harness, "scripts", "python"))
try:
import harness_hash # noqa: E402
return harness_hash.compute(harness), "computed"
except Exception as exc: # noqa: BLE001
return "unavailable:%s" % exc, "error"
def compute_harness_hash(harness):
"""For PINNING at init: use the value recorded at install time if present
(it equals a live compute of the same files), else compute live. Verify must
NOT use this — it must call compute_live() to detect drift."""
recorded = os.path.join(install_root(harness), ".harness-hash")
try:
with open(recorded, "r", encoding="utf-8") as fh:
v = fh.read().strip()
if v:
return v, "recorded"
except (OSError, IOError):
pass
return compute_live(harness)
def _write(path, text, backups):
if os.path.exists(path):
bak = path + ".casan-bak"
if 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)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
def _copy_template(src, dst, backups):
try:
with open(src, "r", encoding="utf-8") as fh:
_write(dst, fh.read(), backups)
return True
except (OSError, IOError):
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 _remove_marker_hooks(doc, marker):
"""Remove only handlers owned by CASAN, preserving all user hook groups."""
changed = False
hooks = doc.get("hooks") if isinstance(doc, dict) else None
if not isinstance(hooks, dict):
return changed
for event in list(hooks):
groups = hooks.get(event)
if not isinstance(groups, list):
continue
kept_groups = []
for group in groups:
if not isinstance(group, dict):
kept_groups.append(group)
continue
handlers = group.get("hooks")
if not isinstance(handlers, list):
kept_groups.append(group)
continue
kept_handlers = []
for handler in handlers:
commands = ""
if isinstance(handler, dict):
commands = "%s %s" % (handler.get("command", ""),
handler.get("commandWindows", ""))
if marker in commands:
changed = True
else:
kept_handlers.append(handler)
if kept_handlers:
updated = dict(group)
updated["hooks"] = kept_handlers
kept_groups.append(updated)
elif handlers:
changed = True
if kept_groups:
hooks[event] = kept_groups
else:
hooks.pop(event, None)
return changed
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. Existing CASAN-owned
handlers are replaced so upgrades cannot leave a stale command behind.
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 = {}
before = json.dumps(doc, ensure_ascii=False, sort_keys=True)
hooks = doc.get("hooks")
if not isinstance(hooks, dict):
hooks = {}
doc["hooks"] = hooks
_remove_marker_hooks(doc, marker)
for event, groups in (tmpl.get("hooks") or {}).items():
cur = hooks.get(event)
if not isinstance(cur, list):
cur = []
hooks[event] = cur
cur.extend(groups if isinstance(groups, list) else [])
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 = (not existed) or before != json.dumps(doc, ensure_ascii=False, sort_keys=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 remove_json_hooks(target_file, marker, backups):
"""Disable one CASAN integration without touching non-CASAN hooks."""
if not os.path.exists(target_file):
return "absent"
doc = _load_json_or(target_file, None)
if not isinstance(doc, dict) or not _remove_marker_hooks(doc, marker):
return "unchanged"
_backup_once(target_file, backups)
with open(target_file, "w", encoding="utf-8") as fh:
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
return "removed"
def clean_legacy_codex_config(target_file, backups):
"""Remove only Plan-21's obsolete `[hooks]`/`[casan]` blocks.
Current Codex enables hooks by default and rejects/ignores the legacy custom
keys. The sentinel comment makes this migration precise; user-authored TOML
is otherwise preserved byte-for-byte.
"""
if not os.path.exists(target_file):
return "absent"
with open(target_file, "r", encoding="utf-8", errors="replace") as fh:
current = fh.read()
sentinel = "# CASAN Plan-20 (added by casan init)"
created_header = "# CASAN Plan-20 Codex config (created by casan init)"
if sentinel not in current and created_header not in current:
return "unchanged"
lines = current.splitlines()
kept = []
skipping = False
for line in lines:
stripped = line.strip()
if stripped == created_header:
continue
if stripped == sentinel:
skipping = True
continue
if skipping and stripped.startswith("[") and stripped != "[casan]":
skipping = False
if not skipping:
kept.append(line)
# Old files created by CASAN put [hooks] immediately before the sentinel.
cleaned = "\n".join(kept)
cleaned = re.sub(
r"(?ms)\n?\[hooks\]\nenabled = true\nproject_hooks = true\n(?=\s*$)",
"\n", cleaned)
cleaned = cleaned.strip() + ("\n" if cleaned.strip() else "")
_backup_once(target_file, backups)
if cleaned:
with open(target_file, "w", encoding="utf-8") as fh:
fh.write(cleaned)
else:
os.unlink(target_file)
return "cleaned"
def select_clients(values, interactive):
"""Normalize repeatable/comma-separated selections.
A plain interactive `casan init` presents the requested IDE menu. In
non-interactive automation, the historical Claude+Codex default is kept.
"""
if not values:
if interactive:
sys.stderr.write(
"\nEnable CASAN integrations (comma-separated numbers):\n"
" 1) Claude Code (CLI + official VS Code extension)\n"
" 2) Codex (CLI + official VS Code extension)\n"
" 3) GitHub Copilot in VS Code via explicit @casan route\n"
"Selection [1,2]: ")
sys.stderr.flush()
answer = sys.stdin.readline().strip() or "1,2"
values = [answer]
else:
values = ["claude,codex"]
aliases = {
"1": "claude",
"2": "codex",
"3": "vscode-copilot",
"copilot": "vscode-copilot",
"vscode": "vscode-copilot",
"github-copilot": "vscode-copilot",
}
selected = []
for value in values:
for raw in str(value).split(","):
item = aliases.get(raw.strip().lower(), raw.strip().lower())
if item == "all":
item_values = list(SUPPORTED_CLIENTS)
elif item in ("none", ""):
item_values = []
elif item in SUPPORTED_CLIENTS:
item_values = [item]
else:
raise ValueError("unknown --client %r (valid: %s, all, none)" %
(raw, ", ".join(SUPPORTED_CLIENTS)))
for normalized in item_values:
if normalized not in selected:
selected.append(normalized)
return selected
def merge_vscode_recommendations(target_file, clients, backups):
existed = os.path.exists(target_file)
doc = _load_json_or(target_file, {}) if existed else {}
if not isinstance(doc, dict):
doc = {}
before = json.dumps(doc, ensure_ascii=False, sort_keys=True)
recommendations = doc.get("recommendations")
if not isinstance(recommendations, list):
recommendations = []
managed = {item for values in VSCODE_EXTENSION_IDS.values() for item in values}
recommendations = [item for item in recommendations if item not in managed]
for client in clients:
for extension_id in VSCODE_EXTENSION_IDS.get(client, ()):
if extension_id not in recommendations:
recommendations.append(extension_id)
if recommendations:
doc["recommendations"] = recommendations
else:
doc.pop("recommendations", None)
changed = (not existed and bool(doc)) or before != json.dumps(
doc, ensure_ascii=False, sort_keys=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 build_vscode_vsix():
builder = os.path.join(devkit_root(), "package-vscode-extension.py")
output = os.path.join(devkit_root(), "dist", "casan-governed-chat.vsix")
if not os.path.isfile(builder):
return None, "builder_missing"
result = subprocess.run(
[sys.executable, builder, "--output", output],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
if result.returncode != 0 or not os.path.isfile(output):
return None, "build_failed:%s" % result.stderr.strip()
return output, "built"
def install_vscode_extension(mode):
"""Install the local CASAN VSIX when requested and `code` is available."""
if mode == "no":
return {"status": "skipped", "reason": "disabled"}
vsix, status = build_vscode_vsix()
if not vsix:
return {"status": "failed", "reason": status}
code = shutil.which("code")
if not code:
return {
"status": "needs_install",
"reason": "code_cli_not_found",
"vsix": vsix,
"action": "Install this VSIX from VS Code: Extensions: Install from VSIX...",
}
result = subprocess.run(
[code, "--install-extension", vsix, "--force"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
return {
"status": "installed" if result.returncode == 0 else "failed",
"extension": "fpt-casan.casan-governed-chat",
"vsix": vsix,
"command": code,
"detail": (result.stdout or result.stderr).strip(),
}
def cmd_init(args):
harness = resolve_harness(args.harness)
if not harness:
sys.stderr.write("casan init: cannot locate the harness. Install CASAN first "
"(install.sh) or set CASAN_HARNESS_ROOT.\n")
return 1
target = os.path.abspath(args.target or os.getcwd())
if not os.path.isdir(target):
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)
try:
clients = select_clients(args.client, sys.stdin.isatty() and not args.non_interactive)
except ValueError as error:
sys.stderr.write("casan init: %s\n" % error)
return 64
version = harness_version(harness)
hhash, hsource = compute_harness_hash(harness)
created = []
backups = []
def created_add(p):
created.append(os.path.relpath(p, target))
# ── .casan/config.json ──
cfg_dir = os.path.join(target, ".casan")
cfg = {
"schema_version": "21.1",
"project_id": project,
"created_at": now_iso(),
"enforcement_mode": args.mode,
"integration_mode": args.integration_mode,
"clients": clients,
"client_capabilities": {
"claude": "project_hook" if "claude" in clients else "disabled",
"codex": "project_hook_trust_required" if "codex" in clients else "disabled",
"vscode-copilot": "casan_owned_explicit_at_casan"
if "vscode-copilot" in clients else "disabled",
"vscode-native-copilot": "unsupported_global_interception",
},
"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)
# ── .casan/version.lock (pin) ──
lock = {
"harness_version": version,
"harness_hash": hhash,
"hash_algo": "sha256",
"hash_source": hsource,
"install_root": install_root(harness),
"recorded_at": now_iso(),
}
p = os.path.join(cfg_dir, "version.lock")
_write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
# ── .casan/agentic.env (Plan-20 flags) ──
env_lines = [
"# CASAN Plan-20 flags (reference/export compatibility).",
"# .casan/casan-hook.py loads the authoritative values from config.json automatically.",
"CASAN_AGENTIC_BRIDGE_ENABLED=1",
"CASAN_AGENTIC_ENFORCEMENT_MODE=%s" % args.mode,
"CASAN_AGENTIC_INTEGRATION_MODE=%s" % args.integration_mode,
"",
]
p = os.path.join(cfg_dir, "agentic.env")
_write(p, "\n".join(env_lines), backups); created_add(p)
# Stable project-local bootstrap. It loads the config above, resolves the
# global harness and verifies version.lock before dispatching an adapter.
bootstrap_source = os.path.join(devkit_root(), "templates", "project",
"casan-hook.py")
bootstrap_target = os.path.join(cfg_dir, "casan-hook.py")
if not os.path.isfile(bootstrap_source):
sys.stderr.write("casan init: missing project hook bootstrap template\n")
return 1
with open(bootstrap_source, "r", encoding="utf-8") as fh:
_write(bootstrap_target, fh.read(), backups)
try:
os.chmod(bootstrap_target, 0o755)
except OSError:
pass
created_add(bootstrap_target)
# ── .specify/ state root marker ──
specify = os.path.join(target, ".specify")
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
gi = os.path.join(specify, ".gitignore")
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 — 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 = {}
claude_dst = os.path.join(target, ".claude", "settings.json")
if "claude" in clients:
dst = claude_dst
remove_json_hooks(dst, "claude_hook.py", backups)
r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"),
"casan-hook.py", backups)
if r and r != "unchanged":
created_add(dst)
merges[".claude/settings.json"] = r
else:
legacy = remove_json_hooks(claude_dst, "claude_hook.py", backups)
current = remove_json_hooks(claude_dst, "casan-hook.py", backups)
merges[".claude/settings.json"] = (
"removed" if "removed" in (legacy, current) else current)
codex_hooks_dst = os.path.join(target, ".codex", "hooks.json")
if "codex" in clients:
dsth = codex_hooks_dst
remove_json_hooks(dsth, "codex_hook.py", backups)
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"),
"casan-hook.py", backups)
if r and r != "unchanged":
created_add(dsth)
merges[".codex/hooks.json"] = r
else:
legacy = remove_json_hooks(codex_hooks_dst, "codex_hook.py", backups)
current = remove_json_hooks(codex_hooks_dst, "casan-hook.py", backups)
merges[".codex/hooks.json"] = (
"removed" if "removed" in (legacy, current) else current)
# Migrate obsolete Plan-21 TOML blocks. Current Codex discovers hooks.json
# directly and requires trust through /hooks; no custom [casan] keys.
merges[".codex/config.toml"] = clean_legacy_codex_config(
os.path.join(target, ".codex", "config.toml"), backups)
vscode_file = os.path.join(target, ".vscode", "extensions.json")
vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups)
if vscode_merge not in ("unchanged", "absent"):
created_add(vscode_file)
merges[".vscode/extensions.json"] = vscode_merge
vscode_install = (
install_vscode_extension(args.vscode_install)
if "vscode-copilot" in clients else
{"status": "skipped", "reason": "vscode-copilot_not_selected"}
)
# ── 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 = {
"created": created,
"backups": [os.path.relpath(b, target) for b in backups],
"project_id": project,
"at": now_iso(),
}
p = os.path.join(cfg_dir, "init-manifest.json")
_write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups)
print(json.dumps({
"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,
"hook_merges": merges,
"vscode_extension": vscode_install,
"level_extras": level_extras,
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
}, 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")
return 0
def cmd_verify(args):
harness = resolve_harness(args.harness)
if not harness:
sys.stderr.write("casan verify-harness: cannot locate the harness.\n")
return 1
target = os.path.abspath(args.target or os.getcwd())
lock_path = os.path.join(target, ".casan", "version.lock")
if not os.path.exists(lock_path):
sys.stderr.write("casan verify-harness: no .casan/version.lock (run `casan init` first).\n")
return 1
with open(lock_path, "r", encoding="utf-8") as fh:
lock = json.load(fh)
expected = lock.get("harness_hash")
actual, _src = compute_live(harness) # live recompute — never the cached hash
ok = (expected == actual) and expected and not str(expected).startswith("unavailable")
result = {
"status": "ok" if ok else "drift",
"expected": expected,
"actual": actual,
"harness_version_lock": lock.get("harness_version"),
"harness_version_now": harness_version(harness),
"harness_root": harness,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if not ok:
sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the "
"project pin. The global harness changed or was tampered.\n")
return 3
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 _commands_in_hooks(path):
doc = _load_json_or(path, {})
values = []
for groups in (doc.get("hooks") or {}).values():
if not isinstance(groups, list):
continue
for group in groups:
for handler in (group.get("hooks") or []) if isinstance(group, dict) else []:
if isinstance(handler, dict):
values.append(str(handler.get("command", "")))
values.append(str(handler.get("commandWindows", "")))
return values
def _smoke_bootstrap(target, client):
bootstrap = os.path.join(target, ".casan", "casan-hook.py")
event = "Begin" if client == "vscode-copilot" else "UserPromptSubmit"
payload = {
"cwd": target,
"project": target,
"session_id": "casan-doctor",
"turn_id": "casan-doctor-turn",
"prompt": "CASAN doctor smoke",
}
with tempfile.TemporaryDirectory(prefix="casan-doctor-") as tmp:
env = dict(os.environ)
env["CASAN_APP_ROOT"] = target
env["CASAN_STATE_ROOT"] = os.path.join(tmp, ".specify")
result = subprocess.run(
[sys.executable, bootstrap, "--client", client, "--event", event],
input=json.dumps(payload), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, cwd=target, env=env, check=False)
try:
response = json.loads(result.stdout.strip() or "{}")
except ValueError:
response = {}
return {
"ok": result.returncode == 0 and response.get("decision") != "block",
"returncode": result.returncode,
"decision": response.get("decision"),
"trace_id": response.get("trace_id"),
"reason": response.get("reason") or result.stderr.strip() or None,
}
def cmd_doctor(args):
target = os.path.abspath(args.target or os.getcwd())
config_path = os.path.join(target, ".casan", "config.json")
config = _load_json_or(config_path, {})
if not config:
sys.stderr.write("casan doctor: project is not initialized; run `casan init`.\n")
return 1
if args.client:
try:
clients = select_clients(args.client, False)
except ValueError as error:
sys.stderr.write("casan doctor: %s\n" % error)
return 64
else:
clients = config.get("clients") or []
harness = resolve_harness(args.harness)
lock = _load_json_or(os.path.join(target, ".casan", "version.lock"), {})
expected = lock.get("harness_hash")
actual, source = compute_live(harness) if harness else ("unavailable", "error")
integrity_ok = bool(expected and expected == actual)
bootstrap_ok = os.path.isfile(os.path.join(target, ".casan", "casan-hook.py"))
checks = {
"project": target,
"project_id": config.get("project_id"),
"enforcement_mode": config.get("enforcement_mode"),
"integration_mode": config.get("integration_mode"),
"clients": clients,
"harness_root": harness,
"integrity": {
"ok": integrity_ok,
"expected": expected,
"actual": actual,
"source": source,
},
"bootstrap": {"ok": bootstrap_ok},
"client_checks": {},
"warnings": [],
}
ready = integrity_ok and bootstrap_ok
code = shutil.which("code")
installed_extensions = set()
if code:
listed = subprocess.run(
[code, "--list-extensions"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, check=False)
if listed.returncode == 0:
installed_extensions = {
line.strip().lower() for line in listed.stdout.splitlines() if line.strip()}
for client in clients:
item = {"configured": True}
if client == "claude":
commands = _commands_in_hooks(
os.path.join(target, ".claude", "settings.json"))
item["hook_present"] = any(
"casan-hook.py" in command and "--client claude" in command
for command in commands)
item["vscode_extension_installed"] = (
"anthropic.claude-code" in installed_extensions if code else None)
elif client == "codex":
commands = _commands_in_hooks(
os.path.join(target, ".codex", "hooks.json"))
item["hook_present"] = any(
"casan-hook.py" in command and "--client codex" in command
for command in commands)
item["trust_review_required"] = True
item["trust_action"] = "Open /hooks in Codex and trust the current project hook hash."
item["vscode_extension_installed"] = (
"openai.chatgpt" in installed_extensions if code else None)
elif client == "vscode-copilot":
item["hook_present"] = bootstrap_ok
item["route"] = "explicit_@casan"
item["native_copilot_interception"] = False
item["code_cli"] = code
item["extension_installed"] = (
"fpt-casan.casan-governed-chat" in installed_extensions
if code else False)
if not item["extension_installed"]:
item["install_action"] = (
"Re-run `casan init --client vscode-copilot "
"--vscode-install yes` with the VS Code `code` CLI on PATH.")
else:
item["hook_present"] = False
if bootstrap_ok and item.get("hook_present"):
item["smoke"] = _smoke_bootstrap(target, client)
else:
item["smoke"] = {"ok": False, "reason": "configuration_missing"}
client_ready = bool(item.get("hook_present") and item["smoke"].get("ok"))
if client == "vscode-copilot":
client_ready = client_ready and bool(item.get("extension_installed"))
item["ready"] = client_ready
ready = ready and client_ready
checks["client_checks"][client] = item
if "codex" in clients:
checks["warnings"].append(
"Codex project hooks do not run until their exact hash is reviewed and trusted via /hooks.")
if "vscode-copilot" in clients:
checks["warnings"].append(
"Only prompts explicitly sent to @casan use the CASAN-owned Copilot route; "
"built-in Copilot chat is not globally intercepted.")
checks["status"] = "ready" if ready else "not_ready"
print(json.dumps(checks, ensure_ascii=False, indent=2))
return 0 if ready else 2
def main(argv=None):
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
sub = parser.add_subparsers(dest="cmd")
pi = sub.add_parser("init", help="adopt CASAN into the current project (config only)")
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", action="append",
help=("integration to enable; repeat or comma-separate: claude, codex, "
"vscode-copilot, all, none. Interactive init shows a menu."))
pi.add_argument(
"--non-interactive", action="store_true",
help="do not prompt; defaults to the backward-compatible claude,codex set")
pi.add_argument(
"--vscode-install", choices=["auto", "yes", "no"], default="auto",
help=("install the local CASAN @casan VSIX when vscode-copilot is selected "
"(default auto: install when `code` is available)"))
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="enforce",
help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)")
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")
pd = sub.add_parser("doctor", help="verify selected client integrations end-to-end")
pd.add_argument("--target", help="project root (default: cwd)")
pd.add_argument("--client", action="append",
help="client(s) to check; default: clients enabled in config")
pd.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)
if args.cmd == "doctor":
return cmd_doctor(args)
parser.print_help()
return 64
if __name__ == "__main__":
sys.exit(main())