@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.
@@ -23,9 +23,22 @@ import shutil
23
23
  import subprocess
24
24
  import sys
25
25
  import time
26
+ from collections.abc import Callable, Mapping
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
28
29
 
30
+ FIX_HINTS: dict[str, str] = {
31
+ "python-missing": "install Python 3.11 or later; on Linux also run: sudo apt install python-is-python3",
32
+ "python-store-alias": "this is the Microsoft Store alias; install Python from python.org or with winget, then turn off the alias in Settings > Apps > App execution aliases",
33
+ "python-old": "install Python 3.11 or later and make it the first python on PATH",
34
+ "git-missing": "install git (Windows: winget install Git.Git; Linux: sudo apt install git)",
35
+ "keys-none": "optional: run caddis keys to add GLM or DeepSeek keys",
36
+ "deny-rules": "run /caddis:setup-project-ai to add the missing deny rules",
37
+ "gitignore": "run /caddis:setup-project-ai to add the missing .caddis ignore entries",
38
+ "misfiled": "move the file into a caddis document folder, or delete it",
39
+ "host-guard": "see docs/guide/resource-protection.md (install-host-guard.ps1)",
40
+ }
41
+
29
42
  # Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
30
43
  # these — the recorded schemas may be stale and want a re-probe.
31
44
  PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
@@ -36,6 +49,29 @@ AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_do
36
49
  ARTIFACT_DIRS = (".caddis",)
37
50
  ENV_PREFIX = "CADDIS"
38
51
 
52
+ # Known top-level document and working artifact folders under .caddis/
53
+ KNOWN_CADDIS_DIRS: set[str] = {
54
+ "plans",
55
+ "prd",
56
+ "kb",
57
+ "rca",
58
+ "decisions",
59
+ "parking-lot",
60
+ "reviews",
61
+ "todo-list",
62
+ "comms",
63
+ "handoffs",
64
+ "prompts",
65
+ "relay",
66
+ "session-state",
67
+ "advisory-hub-reports",
68
+ "orchestrator",
69
+ "spawn-session",
70
+ "metrics",
71
+ "agent-docs",
72
+ "backlog",
73
+ }
74
+
39
75
 
40
76
  def _home() -> Path:
41
77
  return Path(os.environ.get(f"{ENV_PREFIX}_FAKE_HOME") or Path.home())
@@ -288,7 +324,7 @@ def _file_signals(dest: Path) -> list[str]:
288
324
  signals.append(f"{len(dangling)} dangling DOC-MAP link(s) — run /caddis:kb")
289
325
  days = days_since_last_doctor()
290
326
  if days is not None and days >= 30:
291
- signals.append(f"{days} days since last caddis doctor — run caddis-init --doctor")
327
+ signals.append(f"{days} days since last caddis doctor — run caddis doctor")
292
328
  # Listed LAST but it is the most dangerous signal here: the others degrade a session's quality,
293
329
  # this one silently runs old code and reports success. It is cheap (a file compare) and almost
294
330
  # always empty, so it costs nothing to carry.
@@ -312,19 +348,30 @@ def nudge_line(dest: Path) -> str | None:
312
348
  return f"[caddis] {head}{more}"
313
349
 
314
350
 
351
+ def _load_sibling(name: str, fallback_dir: Path | str | None = None):
352
+ """Load a helper module by filename/module name: sibling first, then fallback_dir."""
353
+ scripts = Path(__file__).resolve().parent
354
+ file_name = name if name.endswith(".py") else f"{name}.py"
355
+ mod_name = name[:-3] if name.endswith(".py") else name
356
+ path = scripts / file_name
357
+ if not path.is_file() and fallback_dir:
358
+ path = Path(fallback_dir) / file_name
359
+ if not path.is_file():
360
+ return None
361
+ spec = importlib.util.spec_from_file_location(f"caddis_{mod_name}", path)
362
+ if spec is None or spec.loader is None:
363
+ return None
364
+ module = importlib.util.module_from_spec(spec)
365
+ spec.loader.exec_module(module)
366
+ return module
367
+
368
+
315
369
  def hook_errors(dest: Path) -> str:
316
370
  """Recent hook failures, counted by hook. Empty when the ledger is clean."""
317
371
  scripts = Path(__file__).resolve().parent
318
- hook_log_path = scripts / "hook_log.py"
319
- if not hook_log_path.is_file(): # source checkout; installed copies are siblings
320
- hook_log_path = scripts.parent / "claude-harness" / "scripts" / "hook_log.py"
321
372
  try:
322
- spec = importlib.util.spec_from_file_location("caddis_hook_log", hook_log_path)
323
- if spec is None or spec.loader is None:
324
- return ""
325
- hook_log = importlib.util.module_from_spec(spec)
326
- spec.loader.exec_module(hook_log)
327
- if not hook_log.summarise(str(dest), days=7):
373
+ hook_log = _load_sibling("hook_log.py", scripts.parent / "claude-harness" / "scripts")
374
+ if hook_log is None or not hook_log.summarise(str(dest), days=7):
328
375
  return ""
329
376
  cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400))
330
377
  counts: dict[str, int] = {}
@@ -342,6 +389,214 @@ def hook_errors(dest: Path) -> str:
342
389
  return "" # a damaged ledger must not break the doctor
343
390
 
344
391
 
392
+ def machine_findings(which: Callable[[str], str | None] | None = None,
393
+ version: Callable[[str], str | None] | None = None) -> list[tuple[str, str]]:
394
+ """Machine faults that stop caddis on a new laptop. Levels: OK, INFO, FAIL (never raises)."""
395
+ which = which or shutil.which # resolved at call time, so a test can patch shutil.which
396
+ version = version or _binary_version
397
+
398
+ out: list[tuple[str, str]] = []
399
+
400
+ # python
401
+ py_path = which("python")
402
+ if py_path is None:
403
+ out.append(("FAIL", f"python (on PATH): not found — {FIX_HINTS['python-missing']}"))
404
+ elif "windowsapps" in py_path.lower() or "WindowsApps" in py_path:
405
+ out.append(("FAIL", f"python (on PATH): [{py_path}] — {FIX_HINTS['python-store-alias']}"))
406
+ else:
407
+ ver_str = None
408
+ try:
409
+ ver_str = version(py_path)
410
+ except Exception:
411
+ ver_str = None
412
+
413
+ m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", ver_str or "")
414
+ if not m:
415
+ out.append(("INFO", "python (on PATH): could not read its version"))
416
+ else:
417
+ major, minor = int(m.group(1)), int(m.group(2))
418
+ ver_display = (ver_str or "").strip()
419
+ if ver_display.startswith("Python "):
420
+ ver_display = ver_display[7:].strip()
421
+ if (major, minor) < (3, 11):
422
+ out.append(("FAIL", f"python (on PATH): {ver_display} [{py_path}] — {FIX_HINTS['python-old']}"))
423
+ else:
424
+ out.append(("OK", f"python (on PATH): {ver_display} [{py_path}]"))
425
+
426
+ # git
427
+ git_path = which("git")
428
+ if git_path is None:
429
+ out.append(("FAIL", f"git: not found — {FIX_HINTS['git-missing']}"))
430
+ else:
431
+ out.append(("OK", f"git: [{git_path}]"))
432
+
433
+ return out
434
+
435
+
436
+ def keys_line(env: Mapping[str, str]) -> tuple[str, str]:
437
+ scripts = Path(__file__).resolve().parent
438
+ try:
439
+ oss_model = _load_sibling("oss_model.py", scripts.parent / "claude-harness" / "scripts")
440
+ if oss_model is None:
441
+ return ("INFO", "keys: could not check")
442
+
443
+ env_dict = dict(env)
444
+ fake_home = env_dict.get(f"{ENV_PREFIX}_FAKE_HOME") or os.environ.get(f"{ENV_PREFIX}_FAKE_HOME")
445
+ if fake_home and "CADDIS_KEYS_FILE" not in env_dict:
446
+ env_dict["CADDIS_KEYS_FILE"] = str(Path(fake_home) / ".caddis" / "keys.env")
447
+
448
+ providers = oss_model.configured_providers(env_dict)
449
+ if providers:
450
+ return ("OK", f"keys: {', '.join(providers)}")
451
+ return ("INFO", f"keys: none — {FIX_HINTS['keys-none']}")
452
+ except Exception:
453
+ return ("INFO", "keys: could not check")
454
+
455
+
456
+ def host_guard_findings(
457
+ run: Callable[..., object] | None = None,
458
+ budget_path: Path | None = None,
459
+ ) -> list[tuple[str, str]]:
460
+ """Check host guard installation on Windows. Levels: WARN (never raises)."""
461
+ if sys.platform != "win32":
462
+ return []
463
+
464
+ run = run or subprocess.run
465
+ out: list[tuple[str, str]] = []
466
+
467
+ try:
468
+ res = run(["sc", "query", "caddis-host-guard"], capture_output=True, text=True)
469
+ rc = getattr(res, "returncode", 0)
470
+ if rc != 0: # 1060 is "no such service"; any other failure also means "cannot confirm it runs"
471
+ out.append(("WARN", f"caddis-host-guard service not found — {FIX_HINTS['host-guard']}"))
472
+ except Exception:
473
+ return []
474
+
475
+ if budget_path is None:
476
+ prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
477
+ budget_path = Path(prog_data) / "caddis" / "host-budget.toml"
478
+
479
+ try:
480
+ if not budget_path.is_file():
481
+ out.append(("WARN", f"{budget_path} missing — {FIX_HINTS['host-guard']}"))
482
+ except Exception:
483
+ pass
484
+
485
+ return out
486
+
487
+
488
+ # ── project checks (read-only — WARN or INFO, never FAIL) ─────────────────────
489
+ def deny_rule_findings(dest: Path) -> list[tuple[str, str]]:
490
+ dest = Path(dest)
491
+ if _is_caddis_source(dest):
492
+ return []
493
+
494
+ scripts = Path(__file__).resolve().parent
495
+ tmpl_path = scripts.parent / "settings.template.json"
496
+ if not tmpl_path.is_file():
497
+ tmpl_path = scripts.parent / "claude-harness" / "settings.template.json"
498
+ if not tmpl_path.is_file():
499
+ return []
500
+
501
+ settings_path = dest / ".claude" / "settings.json"
502
+ if not settings_path.is_file():
503
+ return [("INFO", "no .claude/settings.json")]
504
+
505
+ try:
506
+ tmpl = json.loads(tmpl_path.read_text(encoding="utf-8"))
507
+ required_deny = tmpl.get("permissions", {}).get("deny", [])
508
+ except Exception:
509
+ return []
510
+
511
+ try:
512
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
513
+ dest_deny = set(settings.get("permissions", {}).get("deny", []))
514
+ except Exception:
515
+ dest_deny = set()
516
+
517
+ missing = [r for r in required_deny if r not in dest_deny]
518
+ if missing:
519
+ return [("WARN", f"deny rules: {len(missing)} missing — {FIX_HINTS['deny-rules']}")]
520
+ return []
521
+
522
+
523
+ def gitignore_findings(dest: Path) -> list[tuple[str, str]]:
524
+ dest = Path(dest)
525
+ if _is_caddis_source(dest):
526
+ return []
527
+ if not (dest / ".caddis").is_dir():
528
+ return []
529
+
530
+ scripts = Path(__file__).resolve().parent
531
+ try:
532
+ setup_mod = _load_sibling("setup_project_ai.py", scripts.parent / "scripts")
533
+ if setup_mod is None:
534
+ return [("INFO", "gitignore: could not check")]
535
+ artifact_gitignore = getattr(setup_mod, "ARTIFACT_GITIGNORE", None)
536
+ if not artifact_gitignore:
537
+ return [("INFO", "gitignore: could not check")]
538
+
539
+ entries = [
540
+ line.strip()
541
+ for line in artifact_gitignore.splitlines()
542
+ if line.strip() and not line.strip().startswith("#")
543
+ ]
544
+
545
+ caddis_ignore = dest / ".caddis" / ".gitignore"
546
+ caddis_lines: set[str] = set()
547
+ if caddis_ignore.is_file():
548
+ try:
549
+ caddis_lines = {line.strip() for line in caddis_ignore.read_text(encoding="utf-8", errors="replace").splitlines()}
550
+ except OSError:
551
+ pass
552
+
553
+ root_ignore = dest / ".gitignore"
554
+ root_lines: set[str] = set()
555
+ if root_ignore.is_file():
556
+ try:
557
+ root_lines = {line.strip() for line in root_ignore.read_text(encoding="utf-8", errors="replace").splitlines()}
558
+ except OSError:
559
+ pass
560
+
561
+ missing: list[str] = []
562
+ for entry in entries:
563
+ if entry in caddis_lines or f".caddis/{entry}" in root_lines:
564
+ continue
565
+ missing.append(entry)
566
+
567
+ if missing:
568
+ first_three = ", ".join(missing[:3])
569
+ return [("WARN", f"gitignore: {len(missing)} missing ({first_three}) — {FIX_HINTS['gitignore']}")]
570
+ return []
571
+ except Exception:
572
+ return [("INFO", "gitignore: could not check")]
573
+
574
+
575
+ def misfiled_findings(dest: Path) -> list[tuple[str, str]]:
576
+ dest = Path(dest)
577
+ if _is_caddis_source(dest):
578
+ return []
579
+
580
+ caddis_dir = dest / ".caddis"
581
+ if not caddis_dir.is_dir():
582
+ return []
583
+
584
+ out: list[tuple[str, str]] = []
585
+ try:
586
+ subdirs = sorted(p for p in caddis_dir.iterdir() if p.is_dir())
587
+ except OSError:
588
+ return []
589
+
590
+ for top_dir in subdirs:
591
+ if top_dir.name in _SKIP_DIRS:
592
+ continue
593
+ if top_dir.name not in KNOWN_CADDIS_DIRS:
594
+ md_files = list(top_dir.rglob("*.md"))
595
+ if md_files:
596
+ out.append(("WARN", f".caddis/{top_dir.name}/ is not a caddis folder ({len(md_files)} file(s)) — {FIX_HINTS['misfiled']}"))
597
+ return out
598
+
599
+
345
600
  # ── report ───────────────────────────────────────────────────────────────────
346
601
  def run(dest: Path, quiet: bool) -> int:
347
602
  if quiet: # SessionStart mode: only the nudge, never non-zero
@@ -352,7 +607,7 @@ def run(dest: Path, quiet: bool) -> int:
352
607
 
353
608
  hard = 0
354
609
  print(f"=== caddis doctor — {dest} ===")
355
- print(f"python: {sys.version.split()[0]} ({sys.executable})")
610
+ print(f"python (running this check): {sys.version.split()[0]} ({sys.executable})")
356
611
  for name in ("codex", "agy", "claude"):
357
612
  path = _which(name)
358
613
  if path:
@@ -361,6 +616,32 @@ def run(dest: Path, quiet: bool) -> int:
361
616
  else:
362
617
  print(f" {name:7} not found on PATH (install, or open a new terminal so PATH refreshes)")
363
618
 
619
+ print("-- machine")
620
+ for level, text in machine_findings():
621
+ if level == "FAIL":
622
+ hard = 1
623
+ print(f" {level:4} {text}")
624
+ k_level, k_text = keys_line(os.environ)
625
+ if k_level == "FAIL":
626
+ hard = 1
627
+ print(f" {k_level:4} {k_text}")
628
+
629
+ host = host_guard_findings()
630
+ if host:
631
+ print("-- host")
632
+ for level, text in host:
633
+ print(f" {level:4} {text}")
634
+
635
+ print("-- project")
636
+ proj_findings = deny_rule_findings(dest) + gitignore_findings(dest) + misfiled_findings(dest)
637
+ if proj_findings:
638
+ for level, text in proj_findings:
639
+ if level == "FAIL":
640
+ hard = 1
641
+ print(f" {level:4} {text}")
642
+ else:
643
+ print(" OK deny rules, gitignore entries and document folders clean")
644
+
364
645
  print("-- rules integrity")
365
646
  findings = rules_findings(dest)
366
647
  if findings:
@@ -2,6 +2,8 @@
2
2
  param(
3
3
  [switch]$Apply,
4
4
  [string]$NssmSha256,
5
+ # Where the NSSM binary to copy lives. Default: the first nssm.exe on PATH, else C:\Tools\nssm\nssm.exe.
6
+ [string]$NssmPath,
5
7
  [ValidateSet('report', 'enforce')]
6
8
  [string]$Mode = 'report'
7
9
  )
@@ -9,7 +11,12 @@ param(
9
11
  Set-StrictMode -Version Latest
10
12
  $ErrorActionPreference = 'Stop'
11
13
 
12
- $sourceNssm = 'C:\Tools\nssm\nssm.exe'
14
+ if ($NssmPath) {
15
+ $sourceNssm = $NssmPath
16
+ } else {
17
+ $foundNssm = Get-Command 'nssm.exe' -ErrorAction SilentlyContinue
18
+ if ($foundNssm) { $sourceNssm = $foundNssm.Source } else { $sourceNssm = 'C:\Tools\nssm\nssm.exe' }
19
+ }
13
20
  $serviceName = 'caddis-host-guard'
14
21
  $programRoot = Join-Path $env:ProgramFiles 'caddis'
15
22
  $nssmDir = Join-Path $programRoot 'nssm'