Add selectable CASAN IDE integrations
This commit is contained in:
@@ -29,7 +29,10 @@ 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}$")
|
||||
@@ -39,6 +42,16 @@ PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
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():
|
||||
@@ -187,10 +200,53 @@ def _has_marker_hook(groups, marker):
|
||||
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. Idempotent by `marker`:
|
||||
re-running never duplicates the CASAN hook. Returns created|merged|unchanged|None."""
|
||||
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
|
||||
@@ -198,24 +254,22 @@ def merge_json_hooks(target_file, template_file, marker, backups):
|
||||
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
|
||||
changed = not existed
|
||||
_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
|
||||
if _has_marker_hook(cur, marker):
|
||||
continue
|
||||
cur.extend(groups if isinstance(groups, list) else [])
|
||||
changed = True
|
||||
for k, v in tmpl.items(): # carry template scalars (e.g. "version") if absent
|
||||
if k != "hooks" and k not in doc:
|
||||
doc[k] = v
|
||||
changed = True
|
||||
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)
|
||||
@@ -224,32 +278,176 @@ def merge_json_hooks(target_file, template_file, marker, backups):
|
||||
return ("created" if not existed else "merged") if changed else "unchanged"
|
||||
|
||||
|
||||
def merge_codex_config(target_file, mode, integration_mode, backups):
|
||||
"""Append a CASAN [casan] section (and, only if absent, a [hooks] enabler) to
|
||||
an existing .codex/config.toml without duplicating tables. stdlib-only; no TOML
|
||||
writer needed. Idempotent by the '[casan]' marker."""
|
||||
casan_block = (
|
||||
"\n# CASAN Plan-20 (added by casan init)\n"
|
||||
"[casan]\n"
|
||||
'enforcement_mode = "%s"\n'
|
||||
'integration_mode = "%s"\n' % (mode, integration_mode)
|
||||
)
|
||||
hooks_block = "\n[hooks]\nenabled = true\nproject_hooks = true\n"
|
||||
def remove_json_hooks(target_file, marker, backups):
|
||||
"""Disable one CASAN integration without touching non-CASAN hooks."""
|
||||
if not os.path.exists(target_file):
|
||||
os.makedirs(os.path.dirname(target_file), exist_ok=True)
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write("# CASAN Plan-20 Codex config (created by casan init)\n"
|
||||
+ hooks_block + casan_block)
|
||||
return "created"
|
||||
with open(target_file, "r", encoding="utf-8", errors="replace") as fh:
|
||||
cur = fh.read()
|
||||
if "[casan]" in cur:
|
||||
return "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)
|
||||
add = ("" if "[hooks]" in cur else hooks_block) + casan_block
|
||||
with open(target_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(cur.rstrip() + "\n" + add)
|
||||
return "merged"
|
||||
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):
|
||||
@@ -294,7 +492,11 @@ def cmd_init(args):
|
||||
preview = (lvl == 3) # platform is a separate preview SERVICE; init applies the L2 base
|
||||
apply_devkit = (lvl >= 2)
|
||||
|
||||
clients = ["claude", "codex"] if args.client == "all" else [args.client]
|
||||
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 = []
|
||||
@@ -312,6 +514,13 @@ def cmd_init(args):
|
||||
"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,
|
||||
@@ -334,7 +543,8 @@ def cmd_init(args):
|
||||
|
||||
# ── .casan/agentic.env (Plan-20 flags) ──
|
||||
env_lines = [
|
||||
"# CASAN Plan-20 agentic bridge flags. Source before starting the client.",
|
||||
"# 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,
|
||||
@@ -343,6 +553,22 @@ def cmd_init(args):
|
||||
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)
|
||||
@@ -356,25 +582,49 @@ def cmd_init(args):
|
||||
# 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 = os.path.join(target, ".claude", "settings.json")
|
||||
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"),
|
||||
"claude_hook.py", backups)
|
||||
"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 = os.path.join(target, ".codex", "hooks.json")
|
||||
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"),
|
||||
"codex_hook.py", backups)
|
||||
"casan-hook.py", backups)
|
||||
if r and r != "unchanged":
|
||||
created_add(dsth)
|
||||
merges[".codex/hooks.json"] = r
|
||||
dstc = os.path.join(target, ".codex", "config.toml")
|
||||
r = merge_codex_config(dstc, args.mode, args.integration_mode, backups)
|
||||
if r and r != "unchanged":
|
||||
created_add(dstc)
|
||||
merges[".codex/config.toml"] = r
|
||||
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
|
||||
@@ -414,9 +664,10 @@ def cmd_init(args):
|
||||
"clients": clients,
|
||||
"created": created,
|
||||
"hook_merges": merges,
|
||||
"vscode_extension": vscode_install,
|
||||
"level_extras": level_extras,
|
||||
"note": ("harness NOT copied into repo (hybrid model); client hooks MERGED "
|
||||
"(existing config preserved); run `casan verify-harness` to check the pin"),
|
||||
"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 "
|
||||
@@ -489,6 +740,159 @@ def cmd_level(args):
|
||||
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")
|
||||
@@ -496,10 +900,21 @@ def main(argv=None):
|
||||
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", choices=["claude", "codex", "all"], default="all")
|
||||
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="observe")
|
||||
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",
|
||||
@@ -515,6 +930,12 @@ def main(argv=None):
|
||||
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)
|
||||
@@ -522,6 +943,8 @@ def main(argv=None):
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user