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__.py +2 -0
- guard/agent_integration.py +122 -0
- guard/api.py +397 -0
- guard/backend.py +475 -0
- guard/cli.py +320 -0
- guard/db.py +271 -0
- guard/decision.py +316 -0
- guard/doctor_cmd.py +475 -0
- guard/events.py +77 -0
- guard/feedback.py +86 -0
- guard/hook.py +281 -0
- guard/ingest/__init__.py +1 -0
- guard/ingest/coverage.py +403 -0
- guard/ingest/static.py +290 -0
- guard/init_cmd.py +437 -0
- guard/integrations/__init__.py +1 -0
- guard/integrations/claude.py +195 -0
- guard/state.py +104 -0
- guard/thin_slice.py +278 -0
- guard_cli/__init__.py +37 -0
- guard_cli/api.py +2 -0
- guard_cli/backend.py +2 -0
- guard_cli/cli.py +3 -0
- guard_cli/db.py +2 -0
- guard_cli/decision.py +2 -0
- guard_cli/doctor_cmd.py +2 -0
- guard_cli/events.py +2 -0
- guard_cli/feedback.py +2 -0
- guard_cli/hook.py +2 -0
- guard_cli/ingest/__init__.py +1 -0
- guard_cli/ingest/coverage.py +1 -0
- guard_cli/ingest/static.py +1 -0
- guard_cli/init_cmd.py +2 -0
- guard_cli/integrations/__init__.py +1 -0
- guard_cli/integrations/claude.py +1 -0
- guard_cli/state.py +2 -0
- guard_cli/thin_slice.py +2 -0
- guard_cli-0.1.0.dist-info/METADATA +241 -0
- guard_cli-0.1.0.dist-info/RECORD +43 -0
- guard_cli-0.1.0.dist-info/WHEEL +5 -0
- guard_cli-0.1.0.dist-info/entry_points.txt +3 -0
- guard_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- guard_cli-0.1.0.dist-info/top_level.txt +2 -0
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}"
|