guard-cli 0.1.0__py3-none-any.whl

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.
guard/init_cmd.py ADDED
@@ -0,0 +1,437 @@
1
+ """Guard init command implementation (Work Package C).
2
+
3
+ Steps:
4
+ 1. Find the repo (git root + test configuration)
5
+ 2. Find the test environment (target repo's own virtualenv / python)
6
+ 3. Check pytest-cov (offer to install if missing, never silent)
7
+ 4. Start/resolve backend (Server, Docker, or Embedded)
8
+ 5. Static ingest (fast static parsing with rich progress summary)
9
+ 6. Baseline run (time 1 pytest run, estimate 3x cost, exclude failing tests)
10
+ 7. Coverage ingest (per-test coverage using target environment)
11
+ 8. Record state (HEAD SHA + timestamp in graph and .guard/state.json)
12
+ 9. Write integrations (.claude/settings.json, AGENTS.md, CLAUDE.md, .gitignore)
13
+ """
14
+ from __future__ import annotations
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from rich.console import Console
26
+ from rich.table import Table
27
+
28
+ from guard.backend import (
29
+ GraphBackend,
30
+ get_repo_graph_name,
31
+ resolve_backend,
32
+ save_config,
33
+ )
34
+
35
+ console = Console()
36
+
37
+ INTEGRATION_MARKER_START = "<!-- BEGIN GUARD INTEGRATION -->"
38
+ INTEGRATION_MARKER_END = "<!-- END GUARD INTEGRATION -->"
39
+
40
+ GUARD_INSTRUCTIONS_BLOCK = f"""{INTEGRATION_MARKER_START}
41
+ ## Guard Safety Layer
42
+
43
+ Before declaring any code edits or refactoring complete, run:
44
+ ```bash
45
+ guard run
46
+ ```
47
+
48
+ If Guard flags low trust, unverified call paths, or uncovered changes, it enforces a full test suite run. Do not bypass or suppress Guard failures.
49
+ {INTEGRATION_MARKER_END}"""
50
+
51
+
52
+ def find_repo_root(start_path: str | Path | None = None) -> Path:
53
+ """Locate the git root and verify test configuration exists."""
54
+ p = Path(start_path).resolve() if start_path else Path.cwd()
55
+
56
+ # Try git rev-parse --show-toplevel
57
+ try:
58
+ res = subprocess.run(
59
+ ["git", "rev-parse", "--show-toplevel"],
60
+ cwd=str(p),
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=5,
64
+ )
65
+ if res.returncode == 0 and res.stdout.strip():
66
+ root = Path(res.stdout.strip()).resolve()
67
+ else:
68
+ root = p
69
+ except Exception:
70
+ root = p
71
+
72
+ # Find test configs or test directory
73
+ test_markers = [
74
+ "pyproject.toml",
75
+ "pytest.ini",
76
+ "setup.cfg",
77
+ "tox.ini",
78
+ "tests",
79
+ "test",
80
+ ]
81
+ has_test_config = any((root / m).exists() for m in test_markers)
82
+ if not has_test_config and not (root / ".git").exists():
83
+ raise ValueError(
84
+ f"Directory '{root}' does not appear to be a Python git repository with tests. "
85
+ "Could not find pyproject.toml, pytest.ini, setup.cfg, tox.ini, or tests/."
86
+ )
87
+
88
+ return root
89
+
90
+
91
+ def find_test_environment(repo_path: Path) -> Path:
92
+ """Find the target repo's own virtualenv or Python executable."""
93
+ candidates = [
94
+ repo_path / ".venv",
95
+ repo_path / "venv",
96
+ repo_path / "env",
97
+ repo_path / ".env",
98
+ ]
99
+ if "VIRTUAL_ENV" in os.environ:
100
+ candidates.insert(0, Path(os.environ["VIRTUAL_ENV"]))
101
+
102
+ for c in candidates:
103
+ if c.is_dir():
104
+ # Check bin/python (Unix) or Scripts/python.exe (Windows)
105
+ py_unix = c / "bin" / "python"
106
+ py_win = c / "Scripts" / "python.exe"
107
+ py = py_unix if py_unix.exists() else py_win
108
+ if py.exists():
109
+ try:
110
+ res = subprocess.run(
111
+ [str(py), "-m", "pytest", "--version"],
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=5,
115
+ )
116
+ if res.returncode == 0:
117
+ return py
118
+ except Exception:
119
+ pass
120
+
121
+ # Fallback to current sys.executable
122
+ return Path(sys.executable)
123
+
124
+
125
+ def check_pytest_cov(test_python: Path, auto_yes: bool = False) -> bool:
126
+ """Check if pytest-cov is installed in the target environment; offer to install if missing."""
127
+ try:
128
+ res = subprocess.run(
129
+ [str(test_python), "-c", "import pytest_cov"],
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=5,
133
+ )
134
+ if res.returncode == 0:
135
+ return True
136
+ except Exception:
137
+ pass
138
+
139
+ console.print("[yellow]pytest-cov is not installed in the target environment.[/yellow]")
140
+ install = False
141
+ if auto_yes:
142
+ install = True
143
+ else:
144
+ # Ask interactively if running in a TTY
145
+ if sys.stdin.isatty():
146
+ ans = input("pytest-cov is required for test coverage tracking. Install it into target environment? [Y/n]: ")
147
+ if ans.strip().lower() in ("y", "yes", ""):
148
+ install = True
149
+ else:
150
+ console.print("[dim]Non-interactive environment and --yes not specified; skipping pytest-cov install.[/dim]")
151
+
152
+ if install:
153
+ console.print(f"[cyan]Installing pytest-cov using {test_python}...[/cyan]")
154
+ try:
155
+ res = subprocess.run(
156
+ [str(test_python), "-m", "pip", "install", "pytest-cov"],
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=60,
160
+ )
161
+ if res.returncode == 0:
162
+ console.print("[green]pytest-cov installed successfully.[/green]")
163
+ return True
164
+ else:
165
+ console.print(f"[red]Failed to install pytest-cov:[/red] {res.stderr}")
166
+ except Exception as err:
167
+ console.print(f"[red]Error installing pytest-cov:[/red] {err}")
168
+
169
+ return False
170
+
171
+
172
+ def setup_backend(
173
+ repo_path: Path,
174
+ preference: str | None = None,
175
+ ) -> tuple[GraphBackend, str]:
176
+ """Start and configure backend for repository."""
177
+ graph_name = get_repo_graph_name(repo_path)
178
+ backend = resolve_backend(preference=preference, repo_path=repo_path, autostart=True)
179
+
180
+ desc = backend.describe()
181
+ console.print(f"[bold green]Backend selected:[/bold green] [cyan]{backend.name}[/cyan] ({desc.get('status') or desc.get('connection_str') or desc.get('data_path')})")
182
+
183
+ # Store in .guard/config.toml
184
+ cfg = {
185
+ "backend": {
186
+ "type": backend.name,
187
+ "host": desc.get("host", "localhost"),
188
+ "port": desc.get("port", 6379),
189
+ "graph_name": graph_name,
190
+ }
191
+ }
192
+ save_config(repo_path, cfg)
193
+ return backend, graph_name
194
+
195
+
196
+ def run_static_ingest(
197
+ repo_path: Path,
198
+ graph_name: str,
199
+ backend: GraphBackend,
200
+ commit_sha: str | None = None,
201
+ ) -> Any:
202
+ """Analyze static repository structure and ingest into FalkorDB."""
203
+ from guard.ingest.static import analyze_repo
204
+ from guard import db
205
+
206
+ console.print(f"[cyan]Parsing static call graph for repository '{repo_path.name}'...[/cyan]")
207
+ res = analyze_repo(repo_path, include_tests=True)
208
+
209
+ g = backend.get_graph(graph_name)
210
+ db.reset_graph(g)
211
+ g = backend.get_graph(graph_name)
212
+ db.ensure_schema(g)
213
+ db.write_static(g, res, commit_sha=commit_sha)
214
+
215
+ counts = db.get_schema_counts(g)
216
+ console.print(
217
+ f"[green]Static graph ingested:[/green] "
218
+ f"{counts.get('File', 0)} files, {counts.get('Function', 0)} functions, "
219
+ f"{counts.get('Test', 0)} tests, {counts.get('CALLS', 0)} calls."
220
+ )
221
+ return res
222
+
223
+
224
+ def run_baseline_and_coverage(
225
+ repo_path: Path,
226
+ graph_name: str,
227
+ test_python: Path,
228
+ skip_coverage: bool = False,
229
+ runs_requested: int | None = None,
230
+ auto_yes: bool = False,
231
+ commit_sha: str | None = None,
232
+ ) -> None:
233
+ """Execute baseline suite and per-test coverage ingestion."""
234
+ if skip_coverage:
235
+ console.print("[dim]Skipping coverage ingestion as requested (--skip-coverage).[/dim]")
236
+ return
237
+
238
+ # 1. Baseline timing run
239
+ console.print(f"[cyan]Running single baseline test run with {test_python.name}...[/cyan]")
240
+ start = time.time()
241
+ proc = subprocess.run(
242
+ [str(test_python), "-m", "pytest", "-q"],
243
+ cwd=str(repo_path),
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=180,
247
+ )
248
+ baseline_dur = time.time() - start
249
+
250
+ failing_tests: list[str] = []
251
+ if proc.returncode != 0:
252
+ for line in proc.stdout.splitlines():
253
+ if line.startswith("FAILED "):
254
+ t_name = line.split()[1]
255
+ failing_tests.append(t_name)
256
+ if failing_tests:
257
+ console.print(
258
+ f"[yellow]Warning: {len(failing_tests)} tests failed during baseline run. "
259
+ "These will be excluded from coverage evidence to avoid poisoning the graph.[/yellow]"
260
+ )
261
+
262
+ # Determine num_runs
263
+ if runs_requested is not None:
264
+ runs = runs_requested
265
+ elif auto_yes:
266
+ runs = 3 if baseline_dur < 10.0 else 1
267
+ else:
268
+ if sys.stdin.isatty():
269
+ est_3 = baseline_dur * 3
270
+ ans = input(f"Baseline run took {baseline_dur:.1f}s. A 3-run flake baseline will take ~{est_3:.1f}s. Run 3 runs? [y/N]: ")
271
+ runs = 3 if ans.strip().lower() in ("y", "yes") else 1
272
+ else:
273
+ runs = 1
274
+
275
+ console.print(f"[cyan]Ingesting per-test coverage ({runs} run(s))...[/cyan]")
276
+ from guard.ingest.coverage import ingest_coverage
277
+
278
+ ingest_coverage(
279
+ repo_path=repo_path,
280
+ graph_name=graph_name,
281
+ commit_sha=commit_sha,
282
+ num_runs=runs,
283
+ python_exe=str(test_python),
284
+ )
285
+ console.print("[green]Per-test coverage successfully ingested into graph.[/green]")
286
+
287
+
288
+ def record_state(
289
+ repo_path: Path,
290
+ graph_name: str,
291
+ backend: GraphBackend,
292
+ commit_sha: str | None = None,
293
+ ) -> dict[str, Any]:
294
+ """Record current git HEAD SHA and ingestion timestamp in graph and .guard/state.json."""
295
+ if commit_sha is None:
296
+ try:
297
+ res = subprocess.run(
298
+ ["git", "rev-parse", "HEAD"],
299
+ cwd=str(repo_path),
300
+ capture_output=True,
301
+ text=True,
302
+ timeout=5,
303
+ )
304
+ commit_sha = res.stdout.strip() if res.returncode == 0 else "unknown"
305
+ except Exception:
306
+ commit_sha = "unknown"
307
+
308
+ ts = datetime.now(timezone.utc).isoformat()
309
+
310
+ # Record in graph
311
+ try:
312
+ g = backend.get_graph(graph_name)
313
+ g.query(
314
+ """MERGE (s:RepoState {graph: $graph})
315
+ SET s.head_sha = $sha, s.ingested_at = $ts""",
316
+ {"graph": graph_name, "sha": commit_sha, "ts": ts},
317
+ )
318
+ except Exception:
319
+ pass
320
+
321
+ state_data = {
322
+ "head_sha": commit_sha,
323
+ "ingested_at": ts,
324
+ "graph_name": graph_name,
325
+ "repo_path": str(repo_path),
326
+ }
327
+
328
+ guard_dir = repo_path / ".guard"
329
+ guard_dir.mkdir(parents=True, exist_ok=True)
330
+ state_file = guard_dir / "state.json"
331
+ state_file.write_text(json.dumps(state_data, indent=2) + "\n", encoding="utf-8")
332
+ return state_data
333
+
334
+
335
+ def write_integrations(repo_path: Path, agents: list[str]) -> list[Path]:
336
+ """Safely update .gitignore, .claude/settings.json, AGENTS.md, and CLAUDE.md."""
337
+ modified_files: list[Path] = []
338
+
339
+ # 1. Update .gitignore
340
+ gitignore = repo_path / ".gitignore"
341
+ if gitignore.exists():
342
+ content = gitignore.read_text(encoding="utf-8")
343
+ if ".guard/" not in content and ".guard" not in content:
344
+ new_content = content.rstrip() + "\n.guard/\n"
345
+ gitignore.write_text(new_content, encoding="utf-8")
346
+ modified_files.append(gitignore)
347
+ else:
348
+ gitignore.write_text(".guard/\n", encoding="utf-8")
349
+ modified_files.append(gitignore)
350
+
351
+ # 2. Install Claude Code Stop hook and settings via claude integration
352
+ if "claude" in agents:
353
+ from guard.integrations import claude
354
+ ok, msg = claude.install(repo_path=repo_path, shared=False)
355
+ settings_file = claude.get_settings_path(repo_path=repo_path, shared=False)
356
+ if settings_file.exists() and settings_file not in modified_files:
357
+ modified_files.append(settings_file)
358
+
359
+ # 3. Update AGENTS.md and CLAUDE.md idempotently
360
+ doc_targets = ["AGENTS.md"]
361
+ if "claude" in agents:
362
+ doc_targets.append("CLAUDE.md")
363
+
364
+ for doc_name in doc_targets:
365
+ doc_path = repo_path / doc_name
366
+ if doc_path.exists():
367
+ text = doc_path.read_text(encoding="utf-8")
368
+ if INTEGRATION_MARKER_START in text and INTEGRATION_MARKER_END in text:
369
+ # Replace marked block in-place
370
+ prefix = text.split(INTEGRATION_MARKER_START)[0]
371
+ suffix = text.split(INTEGRATION_MARKER_END)[1]
372
+ updated = prefix.rstrip() + "\n\n" + GUARD_INSTRUCTIONS_BLOCK + "\n" + suffix.lstrip()
373
+ else:
374
+ updated = text.rstrip() + "\n\n" + GUARD_INSTRUCTIONS_BLOCK + "\n"
375
+ else:
376
+ updated = f"# {repo_path.name}\n\n{GUARD_INSTRUCTIONS_BLOCK}\n"
377
+
378
+ doc_path.write_text(updated, encoding="utf-8")
379
+ modified_files.append(doc_path)
380
+
381
+ return modified_files
382
+
383
+
384
+ def run_init(
385
+ repo_path: str | Path | None = None,
386
+ backend_preference: str | None = None,
387
+ skip_coverage: bool = False,
388
+ auto_yes: bool = False,
389
+ agents: str = "claude,cursor,codex",
390
+ runs: int | None = None,
391
+ ) -> int:
392
+ """Execute complete guard init workflow."""
393
+ console.rule("[bold cyan]Guard Repository Initialization[/bold cyan]")
394
+
395
+ # Step 1: Find repo
396
+ root = find_repo_root(repo_path)
397
+ console.print(f"[bold]Target Repository:[/bold] {root}")
398
+
399
+ # Step 2: Find test environment
400
+ test_python = find_test_environment(root)
401
+ console.print(f"[bold]Target Python Environment:[/bold] {test_python}")
402
+
403
+ # Step 3: Check pytest-cov
404
+ cov_available = check_pytest_cov(test_python, auto_yes=auto_yes)
405
+ if not cov_available:
406
+ skip_coverage = True
407
+
408
+ # Step 4: Backend setup
409
+ backend, graph_name = setup_backend(root, preference=backend_preference)
410
+
411
+ # Step 5: Static ingest
412
+ run_static_ingest(root, graph_name, backend)
413
+
414
+ # Step 6 & 7: Baseline and coverage ingest
415
+ run_baseline_and_coverage(
416
+ root,
417
+ graph_name,
418
+ test_python,
419
+ skip_coverage=skip_coverage,
420
+ runs_requested=runs,
421
+ auto_yes=auto_yes,
422
+ )
423
+
424
+ # Step 8: Record state
425
+ state = record_state(root, graph_name, backend)
426
+ console.print(f"[green]Recorded state:[/green] commit [bold]{state['head_sha'][:7]}[/bold] at {state['ingested_at']}")
427
+
428
+ # Step 9: Write integrations
429
+ agent_list = [a.strip().lower() for a in agents.split(",") if a.strip()]
430
+ modified = write_integrations(root, agent_list)
431
+ console.print(f"[green]Configured integrations for:[/green] {', '.join(agent_list)}")
432
+ for m in modified:
433
+ console.print(f" - Updated: [dim]{m.relative_to(root)}[/dim]")
434
+
435
+ console.rule("[bold green]Initialization Complete[/bold green]")
436
+ console.print("Run [bold cyan]guard doctor[/bold cyan] to verify system health.")
437
+ return 0
@@ -0,0 +1 @@
1
+ """Integration writers for coding assistants and editors."""
@@ -0,0 +1,195 @@
1
+ """Claude Code Stop Hook Integration Writer.
2
+
3
+ Installs, uninstalls, and checks status of Guard's Stop hook in Claude Code settings.
4
+ - By default writes to `.claude/settings.local.json` (uncommitted local settings).
5
+ - Supports `--shared` for `.claude/settings.json` (team committed settings).
6
+ - Merges into existing `hooks.Stop` array, preserving all other configuration.
7
+ - Creates `.bak` backups before modifying existing files.
8
+ """
9
+ from __future__ import annotations
10
+ import json
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ HOOK_COMMAND = "guard hook stop"
16
+ DEFAULT_HOOK_TIMEOUT = 180
17
+
18
+
19
+ def get_settings_path(repo_path: str | Path | None = None, shared: bool = False) -> Path:
20
+ """Returns the path to Claude Code settings file."""
21
+ base = Path(repo_path).resolve() if repo_path else Path.cwd()
22
+ if shared:
23
+ return base / ".claude" / "settings.json"
24
+ local_file = base / ".claude" / "settings.local.json"
25
+ shared_file = base / ".claude" / "settings.json"
26
+ if not local_file.exists() and shared_file.exists():
27
+ return shared_file
28
+ return local_file
29
+
30
+
31
+ def _contains_guard_hook(item: Any) -> bool:
32
+ if isinstance(item, dict):
33
+ if item.get("command") == HOOK_COMMAND:
34
+ return True
35
+ inner = item.get("hooks", [])
36
+ if isinstance(inner, list):
37
+ return any(isinstance(h, dict) and h.get("command") == HOOK_COMMAND for h in inner)
38
+ return False
39
+
40
+
41
+ def status(repo_path: str | Path | None = None, shared: bool = False) -> dict[str, Any]:
42
+ """Checks whether Guard Stop hook is installed in Claude settings."""
43
+ settings_file = get_settings_path(repo_path, shared=shared)
44
+ if not settings_file.exists():
45
+ return {
46
+ "installed": False,
47
+ "target_file": str(settings_file),
48
+ "exists": False,
49
+ "shared": shared,
50
+ }
51
+
52
+ try:
53
+ with open(settings_file, "r", encoding="utf-8") as f:
54
+ data = json.load(f)
55
+
56
+ hooks = data.get("hooks", {})
57
+ stop_hooks = hooks.get("Stop", [])
58
+
59
+ installed = any(_contains_guard_hook(h) for h in stop_hooks)
60
+ return {
61
+ "installed": installed,
62
+ "target_file": str(settings_file),
63
+ "exists": True,
64
+ "shared": shared,
65
+ "stop_hooks_count": len(stop_hooks),
66
+ }
67
+ except Exception as exc:
68
+ return {
69
+ "installed": False,
70
+ "target_file": str(settings_file),
71
+ "exists": True,
72
+ "shared": shared,
73
+ "error": str(exc),
74
+ }
75
+
76
+
77
+ def check_all_settings(repo_path: str | Path | None = None) -> dict[str, Any]:
78
+ """Checks whether Guard Stop hook is installed in either local or shared settings."""
79
+ local_stat = status(repo_path=repo_path, shared=False)
80
+ shared_stat = status(repo_path=repo_path, shared=True)
81
+ installed = local_stat["installed"] or shared_stat["installed"]
82
+ active_target = local_stat["target_file"] if local_stat["installed"] else (
83
+ shared_stat["target_file"] if shared_stat["installed"] else local_stat["target_file"]
84
+ )
85
+ return {
86
+ "installed": installed,
87
+ "local": local_stat,
88
+ "shared": shared_stat,
89
+ "active_target": active_target,
90
+ }
91
+
92
+
93
+ def install(repo_path: str | Path | None = None, shared: bool = False) -> tuple[bool, str]:
94
+ """Installs the Guard Stop hook into Claude Code settings.
95
+
96
+ Returns:
97
+ (success: bool, message: str)
98
+ """
99
+ settings_file = get_settings_path(repo_path, shared=shared)
100
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
101
+
102
+ data: dict[str, Any] = {}
103
+ if settings_file.exists():
104
+ # Backup original
105
+ bak_file = settings_file.with_suffix(settings_file.suffix + ".bak")
106
+ shutil.copy2(settings_file, bak_file)
107
+ try:
108
+ with open(settings_file, "r", encoding="utf-8") as f:
109
+ data = json.load(f)
110
+ if not isinstance(data, dict):
111
+ data = {}
112
+ except Exception as exc:
113
+ return False, f"Failed to parse existing {settings_file}: {exc}"
114
+
115
+ hooks = data.setdefault("hooks", {})
116
+ if not isinstance(hooks, dict):
117
+ hooks = {}
118
+ data["hooks"] = hooks
119
+
120
+ stop_hooks = hooks.setdefault("Stop", [])
121
+ if not isinstance(stop_hooks, list):
122
+ stop_hooks = []
123
+ hooks["Stop"] = stop_hooks
124
+
125
+ # Configure command alias
126
+ commands = data.setdefault("commands", {})
127
+ if isinstance(commands, dict):
128
+ commands["guard"] = "guard run"
129
+
130
+ # Check if already present
131
+ for h in stop_hooks:
132
+ if _contains_guard_hook(h):
133
+ return True, f"Guard Stop hook is already installed in {settings_file}"
134
+
135
+ # Claude Code expects matcher objects with a 'hooks' list
136
+ stop_hooks.append({
137
+ "command": HOOK_COMMAND,
138
+ "hooks": [
139
+ {
140
+ "type": "command",
141
+ "command": HOOK_COMMAND,
142
+ "timeout": DEFAULT_HOOK_TIMEOUT,
143
+ }
144
+ ]
145
+ })
146
+
147
+ with open(settings_file, "w", encoding="utf-8") as f:
148
+ json.dump(data, f, indent=2)
149
+
150
+ return True, f"Successfully installed Guard Stop hook into {settings_file}"
151
+
152
+
153
+ def uninstall(repo_path: str | Path | None = None, shared: bool = False) -> tuple[bool, str]:
154
+ """Uninstalls Guard Stop hook from Claude Code settings.
155
+
156
+ Returns:
157
+ (success: bool, message: str)
158
+ """
159
+ settings_file = get_settings_path(repo_path, shared=shared)
160
+ if not settings_file.exists():
161
+ return True, f"Settings file {settings_file} does not exist, nothing to uninstall"
162
+
163
+ try:
164
+ with open(settings_file, "r", encoding="utf-8") as f:
165
+ data = json.load(f)
166
+ if not isinstance(data, dict):
167
+ return True, "No valid settings found"
168
+ except Exception as exc:
169
+ return False, f"Failed to read {settings_file}: {exc}"
170
+
171
+ hooks = data.get("hooks", {})
172
+ if not isinstance(hooks, dict) or "Stop" not in hooks:
173
+ return True, "No Stop hooks found"
174
+
175
+ stop_hooks = hooks["Stop"]
176
+ if not isinstance(stop_hooks, list):
177
+ return True, "No Stop hook array found"
178
+
179
+ # Backup before modification
180
+ bak_file = settings_file.with_suffix(settings_file.suffix + ".bak")
181
+ shutil.copy2(settings_file, bak_file)
182
+
183
+ initial_len = len(stop_hooks)
184
+ hooks["Stop"] = [
185
+ h for h in stop_hooks
186
+ if not _contains_guard_hook(h)
187
+ ]
188
+
189
+ if len(hooks["Stop"]) == initial_len:
190
+ return True, f"Guard Stop hook was not found in {settings_file}"
191
+
192
+ with open(settings_file, "w", encoding="utf-8") as f:
193
+ json.dump(data, f, indent=2)
194
+
195
+ return True, f"Successfully removed Guard Stop hook from {settings_file}"