@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.
@@ -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
- "$PY" -m "$tool" "$@" || rc=$?
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
- npm run 2>/dev/null | grep -q " lint" && { echo "[gate] npm run lint"; npm run lint --silent || fail=1; }
461
- npm run 2>/dev/null | grep -q " typecheck" && { echo "[gate] npm run typecheck"; npm run typecheck --silent || fail=1; }
462
- npm run 2>/dev/null | grep -q " test" && { echo "[gate] npm test"; npm test --silent || fail=1; }
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
- echo "[gate] doc coverage"
473
- "$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
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 os, stat
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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.126",
2
+ "poolVersion": "1.3.128",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.126",
4
+ "antigravity-plugin": "1.3.128",
5
5
  "antigravity-plugin-extras": "1.3.40",
6
6
  "codex": "unknown"
7
7
  }