1949 lines
77 KiB
Python
Executable File
1949 lines
77 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""`casan init` / `casan verify-harness` (Plan-21 production adoption).
|
|
|
|
Adopt CASAN into an EXISTING project using an explicit runtime contract:
|
|
managed mode pins the shared Core under $CASAN_HOME; vendored mode installs the
|
|
same production-only Core under `.casan/runtime/casan-core`.
|
|
|
|
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)
|
|
.casan/runtime/casan-core
|
|
optional self-contained Core (--runtime vendored)
|
|
|
|
`verify` recomputes the resolved harness gate-code hash and compares it to
|
|
version.lock — the pin+VERIFY half. Drift/tamper of the managed or vendored
|
|
runtime relative to what the project pinned is caught here (preserves the
|
|
Plan-16 "gates are trusted code" guarantee).
|
|
|
|
stdlib-only. Resolves the harness via CASAN_HARNESS_ROOT (set by the global
|
|
launcher) or --harness.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
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",
|
|
),
|
|
}
|
|
PROMPT_MARKER_START = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
|
|
PROMPT_MARKER_END = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->"
|
|
CLIENT_LABELS = {
|
|
"claude": "Claude Code (CLI + VS Code + JetBrains)",
|
|
"codex": "Codex local (desktop + CLI + IDE)",
|
|
"vscode-copilot": "VS Code / @casan",
|
|
}
|
|
CLIENT_SURFACES = {
|
|
"claude": {
|
|
"supported_local": [
|
|
"claude-code-cli",
|
|
"claude-code-vscode",
|
|
"claude-code-jetbrains",
|
|
],
|
|
"not_covered": ["claude-desktop", "claude-web"],
|
|
"contract": "shared_claude_code_project_settings",
|
|
},
|
|
"codex": {
|
|
"supported_local": [
|
|
"codex-desktop-local",
|
|
"codex-cli",
|
|
"codex-ide-extension-local",
|
|
],
|
|
"not_covered": ["codex-cloud", "codex-web"],
|
|
"contract": "shared_codex_local_project_hooks",
|
|
},
|
|
"vscode-copilot": {
|
|
"supported_local": ["vscode-copilot-explicit-at-casan"],
|
|
"not_covered": ["github-copilot-built-in-chat"],
|
|
"contract": "casan_owned_explicit_route",
|
|
},
|
|
}
|
|
RUNTIME_MODES = ("managed", "vendored")
|
|
|
|
|
|
def _color(code, text):
|
|
if (not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None or
|
|
os.environ.get("TERM") == "dumb"):
|
|
return text
|
|
return "\033[%sm%s\033[0m" % (code, text)
|
|
|
|
|
|
def _mark(ok):
|
|
return _color("32" if ok else "31", "✓" if ok else "✗")
|
|
|
|
|
|
def _warn_mark():
|
|
return _color("33", "!")
|
|
|
|
|
|
def _heading(text):
|
|
print(_color("1;36", text))
|
|
|
|
|
|
def _details(rows):
|
|
width = max((len(label) for label, _value in rows), default=0)
|
|
for label, value in rows:
|
|
print(" %-*s %s" % (width, label, value))
|
|
|
|
|
|
def _client_names(clients):
|
|
return ", ".join(CLIENT_LABELS.get(client, client) for client in clients) or "None"
|
|
|
|
|
|
def _emit_json_or_human(args, payload, renderer):
|
|
if getattr(args, "json", False):
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
else:
|
|
renderer(payload)
|
|
|
|
|
|
def _render_init(result):
|
|
_heading("%s CASAN initialized" % _mark(True))
|
|
_details([
|
|
("Project", result["project_id"]),
|
|
("Location", result["target"]),
|
|
("Level", "%s (%s)" % (
|
|
result["target_level_name"].capitalize(), result["target_level"])),
|
|
("Runtime", "%s — %s" % (
|
|
result["runtime_mode"].capitalize(), result["runtime_path"])),
|
|
("Mode", result["enforcement_mode"]),
|
|
("Clients", _client_names(result["clients"])),
|
|
("Files", "%d created or updated" % len(result["created"])),
|
|
("Harness", result["harness_version"]),
|
|
])
|
|
print()
|
|
print(_color("1", "Integrations"))
|
|
for path, status in result["hook_merges"].items():
|
|
ok = status not in (None, "absent")
|
|
print(" %s %-26s %s" % (_mark(ok), path, status or "unavailable"))
|
|
extension = result["vscode_extension"]
|
|
if extension.get("status") not in ("skipped",):
|
|
ok = extension.get("status") == "installed"
|
|
print(" %s %-26s %s" % (
|
|
_mark(ok), "VS Code extension", extension.get("status")))
|
|
print()
|
|
print(_color("1", "Next steps"))
|
|
print(" 1. Run `casan doctor`")
|
|
if "codex" in result["clients"]:
|
|
print(" 2. In a local Codex client, open `/hooks` and trust this project's hook.")
|
|
|
|
|
|
def _render_verify(result):
|
|
ok = result["status"] == "ok"
|
|
_heading("%s Harness integrity %s" % (
|
|
_mark(ok), "verified" if ok else "check failed"))
|
|
_details([
|
|
("Version", str(result.get("harness_version_now") or "unknown")),
|
|
("Location", str(result.get("harness_root") or "unavailable")),
|
|
("Expected", str(result.get("expected") or "unavailable")),
|
|
("Actual", str(result.get("actual") or "unavailable")),
|
|
])
|
|
|
|
|
|
def _render_level(result):
|
|
_heading("CASAN packaging level")
|
|
_details([
|
|
("Installed", str(result.get("installed_level") or "unknown")),
|
|
("Project", ("%s (%s)" % (
|
|
result.get("project_target_level_name"),
|
|
result.get("project_target_level")))
|
|
if result.get("project_target_level") else "not initialized"),
|
|
("Runtime", ("%s — %s" % (
|
|
result.get("project_runtime_mode"),
|
|
result.get("project_runtime_path")))
|
|
if result.get("project_target_level") else "not initialized"),
|
|
("Status", str(result.get("project_level_status") or "unknown")),
|
|
])
|
|
print()
|
|
for level, description in result["levels"].items():
|
|
print(" %-14s %s" % (level, description))
|
|
|
|
|
|
def _render_doctor(result):
|
|
ready = result["status"] == "ready"
|
|
_heading("CASAN Doctor")
|
|
print(" %s Harness integrity" % _mark(result["integrity"]["ok"]))
|
|
print(" %s Project bootstrap" % _mark(result["bootstrap"]["ok"]))
|
|
for client, item in result["client_checks"].items():
|
|
label = CLIENT_LABELS.get(client, client)
|
|
print(" %s %s" % (_mark(item.get("ready", False)), label))
|
|
surfaces = item.get("supported_surfaces") or []
|
|
if surfaces:
|
|
print(" Surfaces: %s" % ", ".join(surfaces))
|
|
reason = item.get("smoke", {}).get("reason")
|
|
if reason and not item.get("ready"):
|
|
print(" %s" % reason)
|
|
if result["warnings"]:
|
|
print()
|
|
print(_color("1;33", "Warnings"))
|
|
for warning in result["warnings"]:
|
|
print(" %s %s" % (_warn_mark(), warning))
|
|
print()
|
|
status = _color("1;32", "READY") if ready else _color("1;31", "NOT READY")
|
|
print("Status: %s" % status)
|
|
|
|
|
|
def _render_uninstall(result):
|
|
_heading("%s CASAN removed from project" % _mark(True))
|
|
_details([
|
|
("Project", result.get("project_id") or "unknown"),
|
|
("Location", result["target"]),
|
|
("Hooks", "%d integration files cleaned" % len(result["hook_changes"])),
|
|
("Files", "%d CASAN-owned files removed" % len(result["removed"])),
|
|
("Evidence", "removed" if result["purged"] else "retained"),
|
|
("VS Code extension", result["vscode_extension"]["status"]),
|
|
])
|
|
if result["retained"]:
|
|
print()
|
|
print(_color("1", "Retained for safety"))
|
|
for path in result["retained"]:
|
|
print(" - %s" % path)
|
|
|
|
|
|
@contextmanager
|
|
def owner_writable(path):
|
|
"""Temporarily make an owner-controlled path writable, then restore modes.
|
|
|
|
Some existing shells intentionally commit `.claude`/`.github` directories
|
|
as 0555. CASAN may add its config there only when the current user owns the
|
|
path; it never escalates privileges or leaves permissions broadened.
|
|
"""
|
|
changed = []
|
|
candidates = []
|
|
if os.path.exists(path):
|
|
candidates.append(path)
|
|
parent = os.path.dirname(path)
|
|
while parent and not os.path.exists(parent):
|
|
parent = os.path.dirname(parent)
|
|
if parent and parent not in candidates:
|
|
candidates.append(parent)
|
|
|
|
try:
|
|
for candidate in candidates:
|
|
info = os.stat(candidate)
|
|
mode = stat.S_IMODE(info.st_mode)
|
|
if mode & stat.S_IWUSR:
|
|
continue
|
|
getuid = getattr(os, "getuid", None)
|
|
if getuid is not None and info.st_uid != getuid():
|
|
raise PermissionError(
|
|
"CASAN cannot write %s: current user does not own %s" %
|
|
(path, candidate))
|
|
os.chmod(candidate, mode | stat.S_IWUSR)
|
|
changed.append((candidate, mode))
|
|
yield
|
|
finally:
|
|
for candidate, mode in reversed(changed):
|
|
try:
|
|
os.chmod(candidate, mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
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
|
|
with open(src, "rb") as fh:
|
|
data = fh.read()
|
|
with owner_writable(dst):
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
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"),
|
|
os.environ.get("CASAN_GLOBAL_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):
|
|
local_root = os.path.abspath(os.path.join(harness, "..", ".."))
|
|
for p in (os.path.join(local_root, "VERSION"),
|
|
os.path.join(install_root(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."""
|
|
local_root = os.path.abspath(os.path.join(harness, "..", ".."))
|
|
for recorded in (
|
|
os.path.join(local_root, ".harness-hash"),
|
|
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):
|
|
continue
|
|
return compute_live(harness)
|
|
|
|
|
|
def resolve_project_harness(target, explicit=None):
|
|
lock = _load_json_or(
|
|
os.path.join(target, ".casan", "version.lock"), {})
|
|
runtime_path = lock.get("runtime_path")
|
|
if lock.get("runtime_mode") == "vendored" and isinstance(runtime_path, str):
|
|
root = (
|
|
None if os.path.isabs(runtime_path)
|
|
else _safe_project_path(target, runtime_path)
|
|
)
|
|
if not root:
|
|
return None
|
|
candidate = os.path.join(root, "packages", "casan-harness")
|
|
if os.path.isdir(os.path.join(candidate, "scripts", "bash")):
|
|
return os.path.abspath(candidate)
|
|
return None
|
|
return resolve_harness(explicit)
|
|
|
|
|
|
def _write(path, text, backups):
|
|
with owner_writable(path):
|
|
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 _safe_project_path(target, relative):
|
|
"""Resolve a manifest-owned relative path without allowing target escape."""
|
|
normalized = str(relative).replace("\\", "/").strip("/")
|
|
if not normalized or os.path.isabs(str(relative)):
|
|
return None
|
|
candidate = os.path.abspath(os.path.join(target, *normalized.split("/")))
|
|
target_real = os.path.realpath(target)
|
|
candidate_real = os.path.realpath(candidate)
|
|
try:
|
|
if os.path.commonpath((target_real, candidate_real)) != target_real:
|
|
return None
|
|
except ValueError:
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def _file_sha256(path):
|
|
if not os.path.isfile(path) or os.path.islink(path):
|
|
return None
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
except OSError:
|
|
return None
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _install_vendored_core(target, source_harness):
|
|
"""Atomically install the production Core runtime inside one project."""
|
|
source_root = os.path.abspath(os.path.join(source_harness, "..", ".."))
|
|
copier = os.path.join(source_root, "scripts", "copy-runtime.py")
|
|
source_bin = os.path.join(source_root, "bin", "casan")
|
|
source_version = os.path.join(source_root, "VERSION")
|
|
for required in (source_bin, source_version):
|
|
if not os.path.isfile(required):
|
|
raise RuntimeError(
|
|
"vendored runtime source is incomplete; missing %s" % required)
|
|
|
|
destination = _safe_project_path(
|
|
target, ".casan/runtime/casan-core")
|
|
if not destination:
|
|
raise RuntimeError(
|
|
"vendored runtime path escapes the project through a symlink")
|
|
parent = os.path.dirname(destination)
|
|
os.makedirs(parent, exist_ok=True)
|
|
staging = tempfile.mkdtemp(prefix=".casan-core-", dir=parent)
|
|
try:
|
|
if os.path.isfile(copier):
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
copier,
|
|
"--source-root", source_root,
|
|
"--destination-root", staging,
|
|
"--component", "harness",
|
|
"--clean",
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
result.stderr.strip() or result.stdout.strip() or
|
|
"production runtime copy failed")
|
|
else:
|
|
shutil.copytree(
|
|
source_harness,
|
|
os.path.join(staging, "packages", "casan-harness"))
|
|
vendored_harness = os.path.join(
|
|
staging, "packages", "casan-harness")
|
|
forbidden = [
|
|
relative for relative in (
|
|
"tests",
|
|
"level5",
|
|
"scripts/bash/ci-harness-gate.sh",
|
|
"scripts/bash/security-gate.sh",
|
|
"scripts/bash/dashboard-server.py",
|
|
)
|
|
if os.path.exists(os.path.join(
|
|
vendored_harness, *relative.split("/")))
|
|
]
|
|
if forbidden:
|
|
raise RuntimeError(
|
|
"source is not a production runtime; forbidden paths: %s" %
|
|
", ".join(forbidden))
|
|
os.makedirs(os.path.join(staging, "bin"), exist_ok=True)
|
|
shutil.copy2(source_bin, os.path.join(staging, "bin", "casan"))
|
|
shutil.copy2(source_version, os.path.join(staging, "VERSION"))
|
|
with open(os.path.join(staging, ".casan-level"), "w",
|
|
encoding="utf-8") as fh:
|
|
fh.write("core\n")
|
|
vendored_hash, hash_source = compute_live(vendored_harness)
|
|
if hash_source == "error":
|
|
raise RuntimeError(
|
|
"cannot verify staged vendored Core: %s" % vendored_hash)
|
|
with open(os.path.join(staging, ".harness-hash"), "w",
|
|
encoding="utf-8") as fh:
|
|
fh.write(vendored_hash + "\n")
|
|
if os.path.isdir(destination) and not os.path.islink(destination):
|
|
shutil.rmtree(destination)
|
|
elif os.path.exists(destination) or os.path.islink(destination):
|
|
os.unlink(destination)
|
|
os.replace(staging, destination)
|
|
staging = None
|
|
finally:
|
|
if staging and os.path.isdir(staging):
|
|
shutil.rmtree(staging)
|
|
|
|
file_count = sum(
|
|
len(files) for _root, _dirs, files in os.walk(destination))
|
|
return destination, file_count
|
|
|
|
|
|
def _remove_vendored_core(target):
|
|
destination = _safe_project_path(
|
|
target, ".casan/runtime/casan-core")
|
|
if not destination:
|
|
return False
|
|
if not os.path.exists(destination) and not os.path.islink(destination):
|
|
return False
|
|
with owner_writable(destination):
|
|
if os.path.isdir(destination) and not os.path.islink(destination):
|
|
shutil.rmtree(destination)
|
|
else:
|
|
os.unlink(destination)
|
|
_prune_empty_parents(destination, target)
|
|
return True
|
|
|
|
|
|
def _backup_once(path, backups):
|
|
if backups is None:
|
|
return
|
|
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 owner_writable(bak):
|
|
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:
|
|
with owner_writable(target_file):
|
|
_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"
|
|
with owner_writable(target_file):
|
|
_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 "")
|
|
with owner_writable(target_file):
|
|
_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 is_casan_source_hub(target):
|
|
"""Distinguish this product's source checkout from an adopted vendored app.
|
|
|
|
Older DevKit adoption copied ``packages/casan-harness`` into an application.
|
|
That directory alone therefore does not make the application a CASAN source
|
|
hub. The source checkout also carries the DevKit source, release manifest,
|
|
and root installer.
|
|
"""
|
|
required = (
|
|
os.path.join("packages", "casan-harness", "scripts", "bash",
|
|
"casan-harness.sh"),
|
|
os.path.join("packages", "casan-devkit", "casan-init.py"),
|
|
os.path.join("packaging", "levels.json"),
|
|
"install.sh",
|
|
)
|
|
return all(os.path.isfile(os.path.join(target, item)) for item in required)
|
|
|
|
|
|
def _hybrid_prompt_block(clients):
|
|
enabled = ", ".join(clients) if clients else "none"
|
|
return """<!-- CASAN_PROMPT_ENFORCEMENT_START -->
|
|
## CASAN IDE governance — mandatory
|
|
|
|
- `.casan/config.json` is the source of truth for enabled integrations (currently: %s).
|
|
- Claude Code and Codex normal prompts are governed only when their integration is enabled and the project hooks are loaded; Codex additionally requires `/hooks` trust review.
|
|
- GitHub Copilot built-in chat is not globally intercepted. Use the explicit `@casan` participant for a CASAN-owned Copilot turn.
|
|
- A retained `bin/casan-chat` entrypoint remains a compatible governed route, but it is no longer the only supported prompt boundary.
|
|
- Do not claim CASAN certification without the matching trace/evidence, and do not bypass a denied or degraded decision.
|
|
<!-- CASAN_PROMPT_ENFORCEMENT_END -->""" % enabled
|
|
|
|
|
|
def _replace_managed_prompt_block(path, replacement, backups):
|
|
if not os.path.isfile(path):
|
|
return "absent"
|
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
current = fh.read()
|
|
pattern = re.compile(
|
|
re.escape(PROMPT_MARKER_START) + r".*?" +
|
|
re.escape(PROMPT_MARKER_END), re.DOTALL)
|
|
if not pattern.search(current):
|
|
return "unmanaged"
|
|
updated = pattern.sub(replacement, current)
|
|
if updated == current:
|
|
return "unchanged"
|
|
with owner_writable(path):
|
|
_backup_once(path, backups)
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(updated.rstrip() + "\n")
|
|
return "migrated"
|
|
|
|
|
|
def _legacy_instruction_conflicts(target):
|
|
"""Report unmarked legacy prose that init must not silently rewrite."""
|
|
conflicts = []
|
|
phrases = (
|
|
"The supported prompt boundary is `bin/casan-chat`",
|
|
"CASAN Core が intercept できない",
|
|
"Direct Claude, ChatGPT, Codex or Copilot UI prompts are outside",
|
|
)
|
|
for relative in ("AGENTS.md", "CLAUDE.md",
|
|
os.path.join(".github", "copilot-instructions.md")):
|
|
path = os.path.join(target, relative)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
current = fh.read()
|
|
unmanaged = re.sub(
|
|
re.escape(PROMPT_MARKER_START) + r".*?" +
|
|
re.escape(PROMPT_MARKER_END), "", current, flags=re.DOTALL)
|
|
if any(phrase in unmanaged for phrase in phrases):
|
|
conflicts.append(relative.replace(os.sep, "/"))
|
|
return conflicts
|
|
|
|
|
|
def migrate_vendored_prompt_contract(target, clients, backups):
|
|
"""Migrate only CASAN-owned legacy files; retain all project-owned content."""
|
|
replacement = _hybrid_prompt_block(clients)
|
|
instruction_files = {}
|
|
for relative in ("AGENTS.md", "CLAUDE.md",
|
|
os.path.join(".github", "copilot-instructions.md")):
|
|
instruction_files[relative.replace(os.sep, "/")] = (
|
|
_replace_managed_prompt_block(
|
|
os.path.join(target, relative), replacement, backups))
|
|
|
|
policy_path = os.path.join(target, ".casan", "prompt-policy.json")
|
|
policy_status = "absent"
|
|
if os.path.isfile(policy_path):
|
|
policy = _load_json_or(policy_path, None)
|
|
if isinstance(policy, dict):
|
|
before = json.dumps(policy, ensure_ascii=False, sort_keys=True)
|
|
policy["direct_external_ui"] = "client_dependent_see_config"
|
|
policy["agentic_clients"] = clients
|
|
policy["hybrid_adoption"] = True
|
|
if before != json.dumps(policy, ensure_ascii=False, sort_keys=True):
|
|
with owner_writable(policy_path):
|
|
_backup_once(policy_path, backups)
|
|
with open(policy_path, "w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(
|
|
policy, ensure_ascii=False, indent=2) + "\n")
|
|
policy_status = "migrated"
|
|
else:
|
|
policy_status = "unchanged"
|
|
else:
|
|
policy_status = "unreadable"
|
|
|
|
return {
|
|
"detected": True,
|
|
"vendored_harness": "retained_for_compatibility",
|
|
"managed_instruction_blocks": instruction_files,
|
|
"prompt_policy": policy_status,
|
|
"manual_review": _legacy_instruction_conflicts(target),
|
|
"note": (
|
|
"Existing .claude agents/skills/commands, .github content, CI, "
|
|
"and the vendored harness were preserved."),
|
|
}
|
|
|
|
|
|
def _normalize_runtime_mode(value):
|
|
aliases = {
|
|
"1": "managed",
|
|
"global": "managed",
|
|
"shared": "managed",
|
|
"2": "vendored",
|
|
"local": "vendored",
|
|
"offline": "vendored",
|
|
"self-contained": "vendored",
|
|
"selfcontained": "vendored",
|
|
}
|
|
normalized = aliases.get(str(value).strip().lower(),
|
|
str(value).strip().lower())
|
|
if normalized not in RUNTIME_MODES:
|
|
raise ValueError(
|
|
"unknown runtime %r (choose managed or vendored)" % value)
|
|
return normalized
|
|
|
|
|
|
def select_runtime_mode(explicit, previous, interactive):
|
|
"""Resolve Core placement without surprising existing projects.
|
|
|
|
An explicit flag always wins. Re-init keeps a valid existing selection.
|
|
Only a new interactive adoption opens the placement wizard; automation
|
|
defaults to managed and never waits for input.
|
|
"""
|
|
if explicit:
|
|
return _normalize_runtime_mode(explicit)
|
|
if previous in RUNTIME_MODES:
|
|
return previous
|
|
if not interactive:
|
|
return "managed"
|
|
|
|
while True:
|
|
sys.stderr.write(
|
|
"\nCore runtime placement\n\n"
|
|
" 1) Managed (Recommended)\n"
|
|
" Use the shared CASAN installation, pinned by version and hash.\n"
|
|
" Best for developer workstations and managed CI.\n\n"
|
|
" 2) Vendored\n"
|
|
" Copy production-only Core into .casan/runtime/casan-core.\n"
|
|
" Best for offline, air-gapped, or self-contained repositories.\n\n"
|
|
"Select runtime [1]: ")
|
|
sys.stderr.flush()
|
|
answer = sys.stdin.readline()
|
|
if not answer or not answer.strip():
|
|
return "managed"
|
|
try:
|
|
return _normalize_runtime_mode(answer)
|
|
except ValueError:
|
|
sys.stderr.write(
|
|
"Invalid selection. Enter 1 for Managed or 2 for Vendored.\n")
|
|
|
|
|
|
def _normalize_clients(values):
|
|
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 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 values:
|
|
return _normalize_clients(values)
|
|
if not interactive:
|
|
return _normalize_clients(["claude,codex"])
|
|
|
|
while True:
|
|
sys.stderr.write(
|
|
"\nClient integrations\n\n"
|
|
" 1) Claude Code (CLI + VS Code + JetBrains)\n"
|
|
" 2) Codex local (desktop app + CLI + IDE extension)\n"
|
|
" 3) GitHub Copilot in VS Code via explicit @casan route\n\n"
|
|
"Select clients (comma-separated) [1,2]: ")
|
|
sys.stderr.flush()
|
|
answer = sys.stdin.readline()
|
|
values = [answer.strip() or "1,2"] if answer else ["1,2"]
|
|
try:
|
|
return _normalize_clients(values)
|
|
except ValueError:
|
|
sys.stderr.write(
|
|
"Invalid selection. Enter 1, 2, 3, a comma-separated list, "
|
|
"`all`, or `none`.\n")
|
|
|
|
|
|
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:
|
|
with owner_writable(target_file):
|
|
_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 the CASAN PRODUCT SOURCE HUB into itself.
|
|
# A vendored application from the previous DevKit model also contains
|
|
# packages/casan-harness, so that directory by itself is not sufficient to
|
|
# identify a source hub. Such applications are migrated in place below.
|
|
hub_marker = os.path.join(target, "packages", "casan-harness", "scripts", "bash", "casan-harness.sh")
|
|
source_hub = is_casan_source_hub(target)
|
|
vendored_harness = os.path.isfile(hub_marker) and not source_hub
|
|
if source_hub and not args.force:
|
|
sys.stderr.write(
|
|
"casan init: target is a CASAN SOURCE HUB — refusing to "
|
|
"adopt CASAN into itself (the Plan-20 hooks would block your own agent). "
|
|
"Use --force only if you really mean to.\n")
|
|
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)
|
|
|
|
previous_config = _load_json_or(
|
|
os.path.join(target, ".casan", "config.json"), {})
|
|
previous_runtime_mode = previous_config.get("runtime_mode")
|
|
interactive = (
|
|
sys.stdin.isatty() and not args.non_interactive and not args.json)
|
|
try:
|
|
runtime_mode = select_runtime_mode(
|
|
args.runtime, previous_runtime_mode, interactive)
|
|
clients = select_clients(args.client, interactive)
|
|
except ValueError as error:
|
|
sys.stderr.write("casan init: %s\n" % error)
|
|
return 64
|
|
runtime_removed = False
|
|
runtime_files = 0
|
|
if runtime_mode == "vendored":
|
|
try:
|
|
runtime_root, runtime_files = _install_vendored_core(
|
|
target, harness)
|
|
except (OSError, RuntimeError) as error:
|
|
sys.stderr.write(
|
|
"casan init: cannot install vendored Core runtime: %s\n" %
|
|
error)
|
|
return 1
|
|
active_harness = os.path.join(
|
|
runtime_root, "packages", "casan-harness")
|
|
runtime_path = os.path.relpath(
|
|
runtime_root, target).replace(os.sep, "/")
|
|
else:
|
|
runtime_removed = _remove_vendored_core(target)
|
|
active_harness = harness
|
|
runtime_root = install_root(harness)
|
|
runtime_path = runtime_root
|
|
|
|
version = harness_version(active_harness)
|
|
hhash, hsource = compute_harness_hash(active_harness)
|
|
created = []
|
|
backups = []
|
|
previous_manifest = _load_json_or(
|
|
os.path.join(target, ".casan", "init-manifest.json"), {})
|
|
owned_files = {
|
|
str(path).replace("\\", "/")
|
|
for path in previous_manifest.get("owned_files", [])
|
|
if isinstance(path, str)
|
|
}
|
|
previous_owned_hashes = {
|
|
str(path).replace("\\", "/"): digest
|
|
for path, digest in previous_manifest.get(
|
|
"owned_file_hashes", {}).items()
|
|
if isinstance(path, str) and isinstance(digest, str)
|
|
}
|
|
newly_owned_files = set()
|
|
level_removed = []
|
|
level_retained = []
|
|
if not apply_devkit:
|
|
devkit_owned_files = {
|
|
path for path in owned_files
|
|
if (path.startswith(".gitea/workflows/casan-") or
|
|
path.startswith("apps/"))
|
|
}
|
|
level_removed, level_retained = _remove_owned_project_artifacts(
|
|
target, devkit_owned_files, previous_owned_hashes)
|
|
for relative in level_removed:
|
|
owned_files.discard(relative)
|
|
previous_owned_hashes.pop(relative, None)
|
|
|
|
def created_add(p):
|
|
created.append(os.path.relpath(p, target))
|
|
|
|
def mark_owned_if_absent(p):
|
|
if not os.path.exists(p):
|
|
relative = os.path.relpath(p, target).replace(os.sep, "/")
|
|
owned_files.add(relative)
|
|
newly_owned_files.add(relative)
|
|
|
|
# ── .casan/config.json ──
|
|
cfg_dir = os.path.join(target, ".casan")
|
|
cfg = {
|
|
"schema_version": "21.2",
|
|
"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",
|
|
},
|
|
"client_surfaces": {
|
|
client: {
|
|
"enabled": client in clients,
|
|
**surface,
|
|
}
|
|
for client, surface in CLIENT_SURFACES.items()
|
|
},
|
|
"harness_version": version,
|
|
"adoption_model": (
|
|
"managed-global" if runtime_mode == "managed"
|
|
else "vendored-project"),
|
|
"runtime_mode": runtime_mode,
|
|
"runtime_path": runtime_path,
|
|
"target_level": lvl,
|
|
"target_level_name": lvl_name,
|
|
}
|
|
p = os.path.join(cfg_dir, "config.json")
|
|
mark_owned_if_absent(p)
|
|
_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": runtime_root,
|
|
"runtime_mode": runtime_mode,
|
|
"runtime_path": runtime_path,
|
|
"recorded_at": now_iso(),
|
|
}
|
|
p = os.path.join(cfg_dir, "version.lock")
|
|
mark_owned_if_absent(p)
|
|
_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")
|
|
mark_owned_if_absent(p)
|
|
_write(p, "\n".join(env_lines), backups); created_add(p)
|
|
|
|
# Stable project-local bootstrap. It loads the config above, resolves the
|
|
# managed/vendored Core and verifies version.lock before dispatching.
|
|
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
|
|
mark_owned_if_absent(bootstrap_target)
|
|
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")
|
|
with owner_writable(os.path.join(specify, "state")):
|
|
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
|
|
with owner_writable(os.path.join(specify, "logs")):
|
|
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
|
|
gi = os.path.join(specify, ".gitignore")
|
|
if not os.path.exists(gi):
|
|
mark_owned_if_absent(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
|
|
mark_owned_if_absent(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
|
|
mark_owned_if_absent(dsth)
|
|
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")
|
|
mark_owned_if_absent(vscode_file)
|
|
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"}
|
|
)
|
|
|
|
legacy_migration = (
|
|
migrate_vendored_prompt_contract(target, clients, backups)
|
|
if vendored_harness else
|
|
{"detected": False}
|
|
)
|
|
|
|
# ── 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):
|
|
relative = os.path.relpath(
|
|
ci_dst, target).replace(os.sep, "/")
|
|
owned_files.add(relative)
|
|
newly_owned_files.add(relative)
|
|
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")
|
|
created_before_domain = len(created)
|
|
n = _copy_tree_missing(dom_src, dom_dst, target, created)
|
|
if n:
|
|
domain_files = {
|
|
path.replace(os.sep, "/")
|
|
for path in created[created_before_domain:]
|
|
}
|
|
owned_files.update(domain_files)
|
|
newly_owned_files.update(domain_files)
|
|
level_extras.append("apps/%s/domain (%d files)" % (project, n))
|
|
|
|
# ── manifest (so uninstall/verify know what init created) ──
|
|
owned_file_hashes = {
|
|
path: digest
|
|
for path, digest in previous_owned_hashes.items()
|
|
if path in owned_files
|
|
}
|
|
for relative in newly_owned_files:
|
|
owned_path = _safe_project_path(target, relative)
|
|
digest = _file_sha256(owned_path) if owned_path else None
|
|
if digest:
|
|
owned_file_hashes[relative] = digest
|
|
manifest = {
|
|
"manifest_version": 2,
|
|
"created": created,
|
|
"backups": [os.path.relpath(b, target) for b in backups],
|
|
"owned_files": sorted(owned_files),
|
|
"owned_file_hashes": dict(sorted(owned_file_hashes.items())),
|
|
"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)
|
|
|
|
result = {
|
|
"status": "initialized",
|
|
"project_id": project,
|
|
"target": target,
|
|
"target_level": lvl,
|
|
"target_level_name": lvl_name,
|
|
"harness_version": version,
|
|
"harness_hash": hhash,
|
|
"runtime_mode": runtime_mode,
|
|
"runtime_path": runtime_path,
|
|
"runtime_files": runtime_files,
|
|
"runtime_removed": runtime_removed,
|
|
"enforcement_mode": args.mode,
|
|
"clients": clients,
|
|
"created": created,
|
|
"hook_merges": merges,
|
|
"vscode_extension": vscode_install,
|
|
"legacy_migration": legacy_migration,
|
|
"level_extras": level_extras,
|
|
"level_removed": level_removed,
|
|
"level_retained": level_retained,
|
|
"note": (
|
|
"managed runtime is referenced by version/hash lock"
|
|
if runtime_mode == "managed" else
|
|
"production-only Core runtime vendored under .casan/runtime/casan-core"
|
|
),
|
|
}
|
|
_emit_json_or_human(args, result, _render_init)
|
|
if legacy_migration.get("manual_review"):
|
|
sys.stderr.write(
|
|
"casan init: WARNING — legacy CASAN prose outside managed markers "
|
|
"still needs review in: %s\n" %
|
|
", ".join(legacy_migration["manual_review"]))
|
|
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):
|
|
target = os.path.abspath(args.target or os.getcwd())
|
|
harness = resolve_project_harness(target, args.harness)
|
|
if not harness:
|
|
sys.stderr.write("casan verify-harness: cannot locate the project runtime.\n")
|
|
return 1
|
|
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,
|
|
}
|
|
_emit_json_or_human(args, result, _render_verify)
|
|
if not ok:
|
|
sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved Core runtime does not match "
|
|
"the project pin. The runtime 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_runtime_mode": cfg.get("runtime_mode", "managed"),
|
|
"project_runtime_path": cfg.get("runtime_path"),
|
|
"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",
|
|
},
|
|
}
|
|
_emit_json_or_human(args, out, _render_level)
|
|
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_project_harness(target, 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
|
|
vendored_harness = os.path.isfile(os.path.join(
|
|
target, "packages", "casan-harness", "scripts", "bash",
|
|
"casan-harness.sh"))
|
|
legacy_conflicts = _legacy_instruction_conflicts(target)
|
|
checks["legacy_migration"] = {
|
|
"vendored_harness_present": vendored_harness,
|
|
"vendored_harness_status": (
|
|
"retained_for_compatibility" if vendored_harness else "not_present"),
|
|
"instruction_files_requiring_review": legacy_conflicts,
|
|
}
|
|
|
|
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:
|
|
surface = CLIENT_SURFACES.get(client, {})
|
|
item = {
|
|
"configured": True,
|
|
"supported_surfaces": list(surface.get("supported_local", [])),
|
|
"not_covered_surfaces": list(surface.get("not_covered", [])),
|
|
"surface_contract": surface.get("contract"),
|
|
}
|
|
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.")
|
|
if vendored_harness:
|
|
checks["warnings"].append(
|
|
"A legacy vendored packages/casan-harness is retained. Remove it only "
|
|
"after CI, bin scripts, and domain smoke tests use the global harness.")
|
|
if legacy_conflicts:
|
|
checks["warnings"].append(
|
|
"Legacy prompt-boundary prose outside CASAN-managed markers conflicts "
|
|
"with Plan-20 hooks and needs manual review: %s." %
|
|
", ".join(legacy_conflicts))
|
|
checks["status"] = "ready" if ready else "not_ready"
|
|
_emit_json_or_human(args, checks, _render_doctor)
|
|
return 0 if ready else 2
|
|
|
|
|
|
def _clean_integration_json(path, metadata_key, metadata_prefix, remove_if_empty):
|
|
"""Remove CASAN template metadata and delete a CASAN-created empty file."""
|
|
if not os.path.isfile(path):
|
|
return False
|
|
doc = _load_json_or(path, None)
|
|
if not isinstance(doc, dict):
|
|
return False
|
|
metadata = doc.get(metadata_key)
|
|
if isinstance(metadata, str) and metadata.startswith(metadata_prefix):
|
|
doc.pop(metadata_key, None)
|
|
hooks = doc.get("hooks")
|
|
if isinstance(hooks, dict) and not hooks:
|
|
doc.pop("hooks", None)
|
|
recommendations = doc.get("recommendations")
|
|
if isinstance(recommendations, list) and not recommendations:
|
|
doc.pop("recommendations", None)
|
|
with owner_writable(path):
|
|
if not doc and remove_if_empty:
|
|
os.unlink(path)
|
|
else:
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
|
|
return True
|
|
|
|
|
|
def _remove_owned_file(path, removed, target):
|
|
if not os.path.isfile(path) and not os.path.islink(path):
|
|
return
|
|
with owner_writable(path):
|
|
os.unlink(path)
|
|
removed.append(os.path.relpath(path, target).replace(os.sep, "/"))
|
|
|
|
|
|
def _prune_empty_directory(path):
|
|
try:
|
|
os.rmdir(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _prune_empty_parents(path, target):
|
|
parent = os.path.dirname(path)
|
|
while parent and parent != target:
|
|
try:
|
|
os.rmdir(parent)
|
|
except OSError:
|
|
break
|
|
parent = os.path.dirname(parent)
|
|
|
|
|
|
def _remove_owned_project_artifacts(target, owned_files, owned_file_hashes):
|
|
"""Remove safe standalone artifacts while preserving merged/user content."""
|
|
removed = []
|
|
retained = []
|
|
integration_owned_files = {
|
|
".claude/settings.json",
|
|
".codex/hooks.json",
|
|
".codex/config.toml",
|
|
".vscode/extensions.json",
|
|
".specify/.gitignore",
|
|
}
|
|
for relative in sorted(
|
|
owned_files, key=lambda item: (item.count("/"), item),
|
|
reverse=True):
|
|
if (relative in integration_owned_files or
|
|
relative.startswith(".casan/")):
|
|
continue
|
|
path = _safe_project_path(target, relative)
|
|
if not path:
|
|
retained.append("%s (invalid ownership path)" % relative)
|
|
continue
|
|
if not os.path.isfile(path) and not os.path.islink(path):
|
|
continue
|
|
expected_hash = owned_file_hashes.get(relative)
|
|
current_hash = _file_sha256(path)
|
|
casan_namespaced_workflow = (
|
|
relative.startswith(".gitea/workflows/casan-") and
|
|
relative.endswith((".yml", ".yaml")))
|
|
if casan_namespaced_workflow or (
|
|
expected_hash and current_hash == expected_hash):
|
|
_remove_owned_file(path, removed, target)
|
|
_prune_empty_parents(path, target)
|
|
else:
|
|
retained.append(
|
|
"%s (modified or legacy file; preserved)" % relative)
|
|
return removed, retained
|
|
|
|
|
|
def _uninstall_vscode_extension(enabled):
|
|
if not enabled:
|
|
return {
|
|
"status": "retained_shared",
|
|
"reason": "use --remove-vscode-extension to remove it from this machine",
|
|
}
|
|
code = shutil.which("code")
|
|
if not code:
|
|
return {"status": "not_removed", "reason": "code_cli_not_found"}
|
|
result = subprocess.run(
|
|
[code, "--uninstall-extension", "fpt-casan.casan-governed-chat"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
|
|
return {
|
|
"status": "uninstalled" if result.returncode == 0 else "failed",
|
|
"command": code,
|
|
"detail": (result.stdout or result.stderr).strip(),
|
|
}
|
|
|
|
|
|
def cmd_uninstall(args):
|
|
"""Remove one project's CASAN adoption without clobbering user config."""
|
|
target = os.path.abspath(args.target or os.getcwd())
|
|
config_path = os.path.join(target, ".casan", "config.json")
|
|
manifest_path = os.path.join(target, ".casan", "init-manifest.json")
|
|
config = _load_json_or(config_path, {})
|
|
manifest = _load_json_or(manifest_path, {})
|
|
specify_gitignore = os.path.join(target, ".specify", ".gitignore")
|
|
try:
|
|
with open(specify_gitignore, "r", encoding="utf-8") as fh:
|
|
has_casan_evidence = fh.read().startswith(
|
|
"# CASAN runtime state — do not commit")
|
|
except OSError:
|
|
has_casan_evidence = False
|
|
if (not config and not manifest and
|
|
not (args.purge and has_casan_evidence)):
|
|
sys.stderr.write(
|
|
"casan uninstall: project is not initialized; nothing to remove.\n")
|
|
return 1
|
|
|
|
created = {
|
|
str(path).replace("\\", "/")
|
|
for path in manifest.get("created", [])
|
|
if isinstance(path, str)
|
|
}
|
|
owned_files = {
|
|
str(path).replace("\\", "/")
|
|
for path in manifest.get("owned_files", [])
|
|
if isinstance(path, str)
|
|
}
|
|
owned_file_hashes = {
|
|
str(path).replace("\\", "/"): digest
|
|
for path, digest in manifest.get("owned_file_hashes", {}).items()
|
|
if isinstance(path, str) and isinstance(digest, str)
|
|
}
|
|
hook_changes = {}
|
|
integration_files = (
|
|
(".claude/settings.json", "claude_hook.py", "casan-hook.py"),
|
|
(".codex/hooks.json", "codex_hook.py", "casan-hook.py"),
|
|
)
|
|
for relative, legacy_marker, current_marker in integration_files:
|
|
path = os.path.join(target, *relative.split("/"))
|
|
legacy = remove_json_hooks(path, legacy_marker, None)
|
|
current = remove_json_hooks(path, current_marker, None)
|
|
status = "removed" if "removed" in (legacy, current) else current
|
|
hook_changes[relative] = status
|
|
|
|
codex_config = os.path.join(target, ".codex", "config.toml")
|
|
hook_changes[".codex/config.toml"] = clean_legacy_codex_config(
|
|
codex_config, None)
|
|
|
|
vscode_path = os.path.join(target, ".vscode", "extensions.json")
|
|
hook_changes[".vscode/extensions.json"] = merge_vscode_recommendations(
|
|
vscode_path, [], None)
|
|
|
|
_clean_integration_json(
|
|
os.path.join(target, ".claude", "settings.json"),
|
|
"//", "CASAN Plan-20", ".claude/settings.json" in owned_files)
|
|
_clean_integration_json(
|
|
os.path.join(target, ".codex", "hooks.json"),
|
|
"description", "CASAN Plan-20", ".codex/hooks.json" in owned_files)
|
|
_clean_integration_json(
|
|
vscode_path, "_casan_unused", "",
|
|
".vscode/extensions.json" in owned_files)
|
|
|
|
removed, retained = _remove_owned_project_artifacts(
|
|
target, owned_files, owned_file_hashes)
|
|
if _remove_vendored_core(target):
|
|
removed.append(".casan/runtime/casan-core")
|
|
|
|
for relative in (
|
|
".casan/config.json",
|
|
".casan/version.lock",
|
|
".casan/agentic.env",
|
|
".casan/casan-hook.py",
|
|
".casan/init-manifest.json"):
|
|
_remove_owned_file(
|
|
os.path.join(target, *relative.split("/")), removed, target)
|
|
|
|
if args.purge:
|
|
for relative in (".specify/logs", ".specify/state"):
|
|
path = os.path.join(target, *relative.split("/"))
|
|
if os.path.isdir(path):
|
|
with owner_writable(path):
|
|
shutil.rmtree(path)
|
|
removed.append(relative)
|
|
if has_casan_evidence:
|
|
_remove_owned_file(specify_gitignore, removed, target)
|
|
|
|
for relative in (
|
|
".casan", ".claude", ".codex", ".vscode", ".specify"):
|
|
_prune_empty_directory(os.path.join(target, relative))
|
|
|
|
if not args.purge and os.path.isdir(os.path.join(target, ".specify")):
|
|
retained.append(".specify/ runtime evidence (use --purge to remove)")
|
|
backups = manifest.get("backups", [])
|
|
if backups:
|
|
retained.append("%d .casan-bak backup(s)" % len(backups))
|
|
|
|
result = {
|
|
"status": "uninstalled",
|
|
"project_id": config.get("project_id") or manifest.get("project_id"),
|
|
"target": target,
|
|
"hook_changes": hook_changes,
|
|
"removed": removed,
|
|
"purged": bool(args.purge),
|
|
"vscode_extension": _uninstall_vscode_extension(
|
|
args.remove_vscode_extension),
|
|
"retained": retained,
|
|
}
|
|
_emit_json_or_human(args, result, _render_uninstall)
|
|
return 0
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
prog="casan-init",
|
|
description="Adopt CASAN governance into an existing project")
|
|
sub = parser.add_subparsers(dest="cmd")
|
|
|
|
pi = sub.add_parser(
|
|
"init",
|
|
help="adopt or reconfigure CASAN in the current project",
|
|
description=(
|
|
"Adopt CASAN governance into an existing project. New interactive "
|
|
"projects are guided through runtime and client selection; existing "
|
|
"projects preserve their current runtime unless --runtime is set."),
|
|
epilog=(
|
|
"examples:\n"
|
|
" casan init\n"
|
|
" casan init --runtime managed --client claude,codex\n"
|
|
" casan init --runtime vendored --client codex\n"
|
|
" casan init --non-interactive --runtime managed --client none"),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
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; new projects default to managed runtime and the "
|
|
"backward-compatible claude,codex client 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="core",
|
|
help="packaging level to adopt: 1|core (default), 2|devkit, 3|platform (preview), 4|enterprise (refused)")
|
|
pi.add_argument(
|
|
"--runtime", choices=["managed", "vendored"],
|
|
help=("Core runtime placement: managed uses the pinned global install "
|
|
"(default for new adoption); vendored copies a production-only "
|
|
"Core into the project; re-init preserves the current mode"))
|
|
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")
|
|
pi.add_argument("--json", action="store_true",
|
|
help="emit the complete machine-readable result")
|
|
|
|
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")
|
|
pv.add_argument("--json", action="store_true",
|
|
help="emit the complete machine-readable result")
|
|
|
|
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")
|
|
pl.add_argument("--json", action="store_true",
|
|
help="emit the complete machine-readable result")
|
|
|
|
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")
|
|
pd.add_argument("--json", action="store_true",
|
|
help="emit the complete machine-readable result")
|
|
|
|
pu = sub.add_parser(
|
|
"uninstall",
|
|
help="remove CASAN from a project while preserving user configuration")
|
|
pu.add_argument("--target", help="project root (default: cwd)")
|
|
pu.add_argument(
|
|
"--purge", action="store_true",
|
|
help="also remove .specify runtime logs and state")
|
|
pu.add_argument(
|
|
"--remove-vscode-extension", action="store_true",
|
|
help="also uninstall the shared @casan VS Code extension from this machine")
|
|
pu.add_argument("--json", action="store_true",
|
|
help="emit the complete machine-readable result")
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.cmd == "init":
|
|
try:
|
|
return cmd_init(args)
|
|
except PermissionError as error:
|
|
sys.stderr.write(
|
|
"casan init: permission denied while merging project config: "
|
|
"%s\n" % error)
|
|
return 77
|
|
if args.cmd == "verify":
|
|
return cmd_verify(args)
|
|
if args.cmd == "level":
|
|
return cmd_level(args)
|
|
if args.cmd == "doctor":
|
|
return cmd_doctor(args)
|
|
if args.cmd == "uninstall":
|
|
return cmd_uninstall(args)
|
|
parser.print_help()
|
|
return 64
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|