@caddis/cli 0.4.39 → 0.4.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +19 -3
- package/bundles/antigravity-plugin/scripts/caddis_heavy_slot.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +313 -66
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +292 -11
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +8 -1
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +258 -9
- package/bundles/antigravity-plugin/settings.template.json +37 -0
- package/bundles/manifest.json +2 -2
- package/dist/cli.js +251 -130
- package/package.json +1 -1
|
@@ -377,6 +377,225 @@ def check_venv(target: Path, install: bool, dry: bool) -> list[str]:
|
|
|
377
377
|
return notes
|
|
378
378
|
|
|
379
379
|
|
|
380
|
+
GATE_STEP_PY = r'''#!/usr/bin/env python3
|
|
381
|
+
"""caddis_gate_step.py — Per-step gate runner with timeout and process tree cleanup.
|
|
382
|
+
|
|
383
|
+
Runs under the PROJECT's interpreter, which may be older than 3.11: no `tomllib` at import time
|
|
384
|
+
(the config file is skipped, with one stderr line, when it is missing) and annotations are not evaluated.
|
|
385
|
+
"""
|
|
386
|
+
from __future__ import annotations
|
|
387
|
+
|
|
388
|
+
import argparse
|
|
389
|
+
import os
|
|
390
|
+
import signal
|
|
391
|
+
import subprocess
|
|
392
|
+
import sys
|
|
393
|
+
import time
|
|
394
|
+
from pathlib import Path
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
import tomllib
|
|
398
|
+
except ImportError: # Python < 3.11
|
|
399
|
+
tomllib = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def format_elapsed(seconds: float) -> str:
|
|
403
|
+
total_s = max(0, int(round(seconds)))
|
|
404
|
+
if total_s < 60:
|
|
405
|
+
return f"{total_s}s"
|
|
406
|
+
m = total_s // 60
|
|
407
|
+
s = total_s % 60
|
|
408
|
+
return f"{m}m{s:02d}s"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def with_faulthandler(argv: list[str], name: str, env: dict[str, str] | None = None) -> list[str]:
|
|
412
|
+
argv = list(argv)
|
|
413
|
+
if name != "pytest":
|
|
414
|
+
return argv
|
|
415
|
+
if any("faulthandler_timeout" in str(arg) for arg in argv):
|
|
416
|
+
return argv
|
|
417
|
+
env_opts = (env.get("PYTEST_ADDOPTS") if env is not None else os.environ.get("PYTEST_ADDOPTS")) or ""
|
|
418
|
+
if "faulthandler_timeout" in env_opts:
|
|
419
|
+
return argv
|
|
420
|
+
return argv + ["-o", "faulthandler_timeout=300"]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def kill_process_tree(proc: subprocess.Popen) -> None:
|
|
424
|
+
if sys.platform == "win32":
|
|
425
|
+
try:
|
|
426
|
+
subprocess.run(
|
|
427
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
428
|
+
capture_output=True,
|
|
429
|
+
timeout=10,
|
|
430
|
+
)
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
try:
|
|
434
|
+
proc.kill()
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
try:
|
|
438
|
+
proc.wait(timeout=5)
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
else:
|
|
442
|
+
try:
|
|
443
|
+
pgid = os.getpgid(proc.pid)
|
|
444
|
+
except Exception:
|
|
445
|
+
pgid = proc.pid
|
|
446
|
+
try:
|
|
447
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
try:
|
|
451
|
+
proc.wait(timeout=5)
|
|
452
|
+
except subprocess.TimeoutExpired:
|
|
453
|
+
try:
|
|
454
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
try:
|
|
458
|
+
proc.wait(timeout=5)
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def main(argv: list[str] | None = None) -> int:
|
|
466
|
+
if argv is None:
|
|
467
|
+
argv = sys.argv[1:]
|
|
468
|
+
|
|
469
|
+
if "--" in argv:
|
|
470
|
+
sep_idx = argv.index("--")
|
|
471
|
+
runner_args = argv[:sep_idx]
|
|
472
|
+
cmd = argv[sep_idx + 1:]
|
|
473
|
+
else:
|
|
474
|
+
runner_args = argv
|
|
475
|
+
cmd = []
|
|
476
|
+
|
|
477
|
+
parser = argparse.ArgumentParser(description="caddis quality gate step runner")
|
|
478
|
+
parser.add_argument("--name", required=True, help="Step name")
|
|
479
|
+
parser.add_argument("--timeout", type=int, required=True, help="Default timeout in seconds")
|
|
480
|
+
parser.add_argument("cmd", nargs="*", help="Command to run (if -- not used)")
|
|
481
|
+
|
|
482
|
+
args = parser.parse_args(runner_args)
|
|
483
|
+
if not cmd:
|
|
484
|
+
cmd = args.cmd
|
|
485
|
+
if cmd and cmd[0] == "--":
|
|
486
|
+
cmd = cmd[1:]
|
|
487
|
+
|
|
488
|
+
if not cmd:
|
|
489
|
+
print("[gate] error: no command specified to run", file=sys.stderr)
|
|
490
|
+
return 1
|
|
491
|
+
|
|
492
|
+
name = args.name
|
|
493
|
+
timeout = args.timeout
|
|
494
|
+
|
|
495
|
+
# Config: read .caddis/config.toml in the current directory if present
|
|
496
|
+
cfg_path = Path(".caddis/config.toml")
|
|
497
|
+
gate_cfg: dict = {}
|
|
498
|
+
if cfg_path.is_file() and tomllib is None:
|
|
499
|
+
print("[gate] note: .caddis/config.toml not read (needs Python 3.11 or later)", file=sys.stderr)
|
|
500
|
+
elif cfg_path.is_file():
|
|
501
|
+
try:
|
|
502
|
+
with open(cfg_path, "rb") as f:
|
|
503
|
+
raw_cfg = tomllib.load(f)
|
|
504
|
+
if isinstance(raw_cfg, dict):
|
|
505
|
+
g = raw_cfg.get("gate", {})
|
|
506
|
+
if isinstance(g, dict):
|
|
507
|
+
gate_cfg = g
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
print(f"[gate] warning: malformed .caddis/config.toml ({exc})", file=sys.stderr)
|
|
510
|
+
|
|
511
|
+
# Section [gate]: timeout_<name> overrides --timeout for that step name
|
|
512
|
+
candidate_keys = (
|
|
513
|
+
f"timeout_{name}",
|
|
514
|
+
f"timeout_{name.replace('-', '_')}",
|
|
515
|
+
f"timeout_{name.replace('_', '-')}",
|
|
516
|
+
)
|
|
517
|
+
for k in candidate_keys:
|
|
518
|
+
if k in gate_cfg:
|
|
519
|
+
val = gate_cfg[k]
|
|
520
|
+
if isinstance(val, int) and not isinstance(val, bool) and val > 0:
|
|
521
|
+
timeout = val
|
|
522
|
+
else:
|
|
523
|
+
print(
|
|
524
|
+
f"[gate] warning: invalid {k}={val!r} in config (must be positive integer)",
|
|
525
|
+
file=sys.stderr,
|
|
526
|
+
)
|
|
527
|
+
break
|
|
528
|
+
|
|
529
|
+
# Env var CADDIS_GATE_TIMEOUT_<NAME upper, - replaced by _> overrides config
|
|
530
|
+
env_var_name = f"CADDIS_GATE_TIMEOUT_{name.upper().replace('-', '_')}"
|
|
531
|
+
if env_var_name in os.environ:
|
|
532
|
+
raw_val = os.environ[env_var_name].strip()
|
|
533
|
+
try:
|
|
534
|
+
env_timeout = int(raw_val)
|
|
535
|
+
if env_timeout > 0:
|
|
536
|
+
timeout = env_timeout
|
|
537
|
+
else:
|
|
538
|
+
print(
|
|
539
|
+
f"[gate] warning: invalid {env_var_name}={raw_val!r} (must be positive integer)",
|
|
540
|
+
file=sys.stderr,
|
|
541
|
+
)
|
|
542
|
+
except ValueError:
|
|
543
|
+
print(
|
|
544
|
+
f"[gate] warning: invalid {env_var_name}={raw_val!r} (must be integer)",
|
|
545
|
+
file=sys.stderr,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# pytest_args (list of strings) are appended to pytest command ONLY when --name is pytest
|
|
549
|
+
if name == "pytest":
|
|
550
|
+
pytest_args = gate_cfg.get("pytest_args")
|
|
551
|
+
if isinstance(pytest_args, list):
|
|
552
|
+
for pa in pytest_args:
|
|
553
|
+
if isinstance(pa, str):
|
|
554
|
+
cmd.append(pa)
|
|
555
|
+
else:
|
|
556
|
+
cmd.append(str(pa))
|
|
557
|
+
|
|
558
|
+
# Pytest hang dump
|
|
559
|
+
cmd = with_faulthandler(cmd, name, os.environ)
|
|
560
|
+
|
|
561
|
+
# Print start line
|
|
562
|
+
print(f"[gate] {name}: start (limit {timeout}s)", flush=True)
|
|
563
|
+
|
|
564
|
+
start_time = time.monotonic()
|
|
565
|
+
kwargs: dict = {}
|
|
566
|
+
if sys.platform == "win32":
|
|
567
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
568
|
+
else:
|
|
569
|
+
kwargs["start_new_session"] = True
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
print(f"[gate] {name}: failed to start {cmd[0]}: {exc}", file=sys.stderr)
|
|
575
|
+
return 1
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
proc.wait(timeout=timeout)
|
|
579
|
+
except subprocess.TimeoutExpired:
|
|
580
|
+
kill_process_tree(proc)
|
|
581
|
+
print(
|
|
582
|
+
f"[gate] {name}: TIMEOUT after {timeout}s - killed the process tree",
|
|
583
|
+
file=sys.stderr,
|
|
584
|
+
flush=True,
|
|
585
|
+
)
|
|
586
|
+
return 124
|
|
587
|
+
|
|
588
|
+
elapsed = time.monotonic() - start_time
|
|
589
|
+
rc = proc.returncode
|
|
590
|
+
print(f"[gate] {name}: done in {format_elapsed(elapsed)} (exit {rc})", flush=True)
|
|
591
|
+
return rc
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
sys.exit(main())
|
|
596
|
+
'''
|
|
597
|
+
|
|
598
|
+
|
|
380
599
|
# Enforced "green before push" gate. Installed into .git/hooks/pre-push. Pure POSIX sh
|
|
381
600
|
# so it runs under Git's bundled shell on Windows/Linux/macOS.
|
|
382
601
|
#
|
|
@@ -398,6 +617,13 @@ PRE_PUSH_HOOK = r"""#!/usr/bin/env sh
|
|
|
398
617
|
set -eu
|
|
399
618
|
echo "[caddis] pre-push quality gate"
|
|
400
619
|
fail=0
|
|
620
|
+
if command -v dirname >/dev/null 2>&1; then
|
|
621
|
+
GATE_RUN="$(dirname "$0")/caddis_gate_step.py"
|
|
622
|
+
elif [ -n "${0%/*}" ] && [ "${0%/*}" != "$0" ]; then
|
|
623
|
+
GATE_RUN="${0%/*}/caddis_gate_step.py"
|
|
624
|
+
else
|
|
625
|
+
GATE_RUN="./caddis_gate_step.py"
|
|
626
|
+
fi
|
|
401
627
|
|
|
402
628
|
# Prefer the project's virtualenv over PATH. Checked in venv-layout order for both
|
|
403
629
|
# Windows (Scripts/) and POSIX (bin/) so the same hook works on either.
|
|
@@ -424,10 +650,20 @@ py_sources_exist() {
|
|
|
424
650
|
|
|
425
651
|
py_gate() {
|
|
426
652
|
tool=$1; shift
|
|
653
|
+
timeout=300
|
|
654
|
+
case "$tool" in
|
|
655
|
+
ruff) timeout=300 ;;
|
|
656
|
+
mypy) timeout=600 ;;
|
|
657
|
+
pytest) timeout=1200 ;;
|
|
658
|
+
esac
|
|
427
659
|
if py_has "$tool"; then
|
|
428
|
-
echo "[gate] $tool $*"
|
|
429
660
|
rc=0
|
|
430
|
-
|
|
661
|
+
if [ -f "$GATE_RUN" ]; then
|
|
662
|
+
"$PY" "$GATE_RUN" --name "$tool" --timeout "$timeout" -- "$PY" -m "$tool" "$@" || rc=$?
|
|
663
|
+
else
|
|
664
|
+
echo "[gate] $tool $*"
|
|
665
|
+
"$PY" -m "$tool" "$@" || rc=$?
|
|
666
|
+
fi
|
|
431
667
|
if [ "$rc" -ne 0 ]; then
|
|
432
668
|
# pytest exit 5 = "no tests collected". A repo that has not written tests yet is not a
|
|
433
669
|
# failing repo, and blocking it would hit exactly the fresh-scaffold case this fix exists
|
|
@@ -457,9 +693,15 @@ if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
|
|
457
693
|
fi
|
|
458
694
|
fi
|
|
459
695
|
if [ -f "package.json" ] && command -v npm >/dev/null 2>&1; then
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
696
|
+
if [ -f "$GATE_RUN" ] && [ -n "$PY" ]; then
|
|
697
|
+
npm run 2>/dev/null | grep -q " lint" && { "$PY" "$GATE_RUN" --name npm-lint --timeout 600 -- npm run lint --silent || fail=1; }
|
|
698
|
+
npm run 2>/dev/null | grep -q " typecheck" && { "$PY" "$GATE_RUN" --name npm-typecheck --timeout 600 -- npm run typecheck --silent || fail=1; }
|
|
699
|
+
npm run 2>/dev/null | grep -q " test" && { "$PY" "$GATE_RUN" --name npm-test --timeout 1200 -- npm test --silent || fail=1; }
|
|
700
|
+
else
|
|
701
|
+
npm run 2>/dev/null | grep -q " lint" && { echo "[gate] npm run lint"; npm run lint --silent || fail=1; }
|
|
702
|
+
npm run 2>/dev/null | grep -q " typecheck" && { echo "[gate] npm run typecheck"; npm run typecheck --silent || fail=1; }
|
|
703
|
+
npm run 2>/dev/null | grep -q " test" && { echo "[gate] npm test"; npm test --silent || fail=1; }
|
|
704
|
+
fi
|
|
463
705
|
fi
|
|
464
706
|
# Doc-coverage discipline (any stack). The checker exits non-zero ONLY on a hard invariant
|
|
465
707
|
# (missing route / dangling doc-map link); soft signals warn without failing. Auto-skips when the
|
|
@@ -469,8 +711,12 @@ if [ -f "scripts/check_doc_coverage.py" ]; then
|
|
|
469
711
|
DOC_PY="$PY"
|
|
470
712
|
if [ -z "$DOC_PY" ]; then DOC_PY=$(command -v python || command -v python3 || true); fi
|
|
471
713
|
if [ -n "$DOC_PY" ]; then
|
|
472
|
-
|
|
473
|
-
|
|
714
|
+
if [ -f "$GATE_RUN" ]; then
|
|
715
|
+
"$DOC_PY" "$GATE_RUN" --name doc-coverage --timeout 120 -- "$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
|
|
716
|
+
else
|
|
717
|
+
echo "[gate] doc coverage"
|
|
718
|
+
"$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
|
|
719
|
+
fi
|
|
474
720
|
fi
|
|
475
721
|
fi
|
|
476
722
|
if [ "$fail" -ne 0 ]; then
|
|
@@ -491,6 +737,7 @@ def install_git_hooks(target: Path, force: bool, dry: bool) -> list[str]:
|
|
|
491
737
|
return ["pre-push gate: .git is a worktree pointer — skipped (install manually if wanted)"]
|
|
492
738
|
hooks_dir = git / "hooks"
|
|
493
739
|
dest = hooks_dir / "pre-push"
|
|
740
|
+
step_dest = hooks_dir / "caddis_gate_step.py"
|
|
494
741
|
if dest.exists() and not force:
|
|
495
742
|
_hook_txt = dest.read_text(encoding="utf-8", errors="ignore")
|
|
496
743
|
managed = "caddis" in _hook_txt or "claudster" in _hook_txt # recognise pre-rename hooks too
|
|
@@ -498,12 +745,14 @@ def install_git_hooks(target: Path, force: bool, dry: bool) -> list[str]:
|
|
|
498
745
|
if not dry:
|
|
499
746
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
500
747
|
dest.write_text(PRE_PUSH_HOOK, encoding="utf-8", newline="\n")
|
|
748
|
+
step_dest.write_text(GATE_STEP_PY, encoding="utf-8", newline="\n")
|
|
501
749
|
try:
|
|
502
|
-
import
|
|
750
|
+
import stat
|
|
503
751
|
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
752
|
+
step_dest.chmod(step_dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
504
753
|
except Exception:
|
|
505
754
|
pass
|
|
506
|
-
notes.append("pre-push gate: installed .git/hooks/pre-push (green-before-push enforced)")
|
|
755
|
+
notes.append("pre-push gate: installed .git/hooks/pre-push and caddis_gate_step.py (green-before-push enforced)")
|
|
507
756
|
return notes
|
|
508
757
|
|
|
509
758
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Base harness settings deployed by setup-project-ai: permissions + a status line. The generator adds stack-specific Bash allow entries (pytest/npm/etc.) based on detection. HOOKS ARE NOT DEFINED HERE: the harness loop hooks (relay.md resume on SessionStart/PreCompact, end-of-session nudge + usage digest on Stop, auto-lint on Edit/Write) ship with the caddis plugin via plugin/hooks/hooks.json and auto-activate on install — defining them here too would double-fire and re-introduce machine-specific paths. The default loop is just a plan file + relay.md; there is no opt-in pipeline/MCP power-mode.",
|
|
3
|
+
"$comment_permissions": "IMPORTANT: any entry in `ask` overrides bypassPermissions (yolo) mode and forces a prompt every time, even when the user has opted into no-prompts. Keep `ask` empty (or as minimal as possible) so bypassPermissions actually bypasses. `Bash(git push:*)` was moved out of `ask` into `allow` for exactly this reason — it was forcing a push prompt on every /ship under bypassPermissions.",
|
|
4
|
+
"permissions": {
|
|
5
|
+
"allow": [
|
|
6
|
+
"Read",
|
|
7
|
+
"Edit",
|
|
8
|
+
"Write",
|
|
9
|
+
"Glob",
|
|
10
|
+
"Grep",
|
|
11
|
+
"Bash(git status)",
|
|
12
|
+
"Bash(git diff:*)",
|
|
13
|
+
"Bash(git add:*)",
|
|
14
|
+
"Bash(git commit:*)",
|
|
15
|
+
"Bash(git log:*)",
|
|
16
|
+
"Bash(git push:*)"
|
|
17
|
+
],
|
|
18
|
+
"ask": [],
|
|
19
|
+
"deny": [
|
|
20
|
+
"Read(**/.env)",
|
|
21
|
+
"Read(**/.env.local)",
|
|
22
|
+
"Read(**/.env.*.local)",
|
|
23
|
+
"Read(**/.env.production)",
|
|
24
|
+
"Read(**/.env.development)",
|
|
25
|
+
"Edit(**/.env)",
|
|
26
|
+
"Edit(**/.env.local)",
|
|
27
|
+
"Edit(**/.env.*.local)",
|
|
28
|
+
"Edit(**/.env.production)",
|
|
29
|
+
"Edit(**/.env.development)",
|
|
30
|
+
"Write(**/.env)",
|
|
31
|
+
"Write(**/.env.local)",
|
|
32
|
+
"Write(**/.env.*.local)",
|
|
33
|
+
"Write(**/.env.production)",
|
|
34
|
+
"Write(**/.env.development)"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|