invar-tools 1.0.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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,183 @@
1
+ """
2
+ Hypothesis fallback for proof verification.
3
+
4
+ DX-12: Provides Hypothesis as automatic fallback when CrossHair
5
+ is unavailable, times out, or skips files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from returns.result import Failure, Result, Success
15
+
16
+
17
+ def run_hypothesis_fallback(
18
+ files: list[Path],
19
+ max_examples: int = 100,
20
+ ) -> Result[dict, str]:
21
+ """
22
+ Run Hypothesis property tests as fallback when CrossHair skips/times out.
23
+
24
+ DX-12: Uses inferred strategies from type hints and @pre contracts.
25
+
26
+ Args:
27
+ files: List of Python file paths to test
28
+ max_examples: Maximum examples per test
29
+
30
+ Returns:
31
+ Success with test results or Failure with error message
32
+ """
33
+ # Import CrossHairStatus here to avoid circular import
34
+ from invar.shell.prove import CrossHairStatus
35
+
36
+ # Check if hypothesis is available
37
+ try:
38
+ import hypothesis # noqa: F401
39
+ except ImportError:
40
+ return Success(
41
+ {
42
+ "status": CrossHairStatus.SKIPPED,
43
+ "reason": "Hypothesis not installed (pip install hypothesis)",
44
+ "files": [],
45
+ "tool": "hypothesis",
46
+ }
47
+ )
48
+
49
+ if not files:
50
+ return Success(
51
+ {
52
+ "status": CrossHairStatus.SKIPPED,
53
+ "reason": "no files",
54
+ "files": [],
55
+ "tool": "hypothesis",
56
+ }
57
+ )
58
+
59
+ # Filter to Python files only
60
+ py_files = [f for f in files if f.suffix == ".py" and f.exists()]
61
+ if not py_files:
62
+ return Success(
63
+ {
64
+ "status": CrossHairStatus.SKIPPED,
65
+ "reason": "no Python files",
66
+ "files": [],
67
+ "tool": "hypothesis",
68
+ }
69
+ )
70
+
71
+ # Use pytest with hypothesis
72
+ cmd = [
73
+ sys.executable,
74
+ "-m",
75
+ "pytest",
76
+ "--hypothesis-show-statistics",
77
+ "--hypothesis-seed=0", # Reproducible
78
+ "-x", # Stop on first failure
79
+ "--tb=short",
80
+ ]
81
+ cmd.extend(str(f) for f in py_files)
82
+
83
+ try:
84
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
85
+ # Pytest exit codes: 0=passed, 5=no tests collected
86
+ is_passed = result.returncode in (0, 5)
87
+ return Success(
88
+ {
89
+ "status": "passed" if is_passed else "failed",
90
+ "files": [str(f) for f in py_files],
91
+ "exit_code": result.returncode,
92
+ "stdout": result.stdout,
93
+ "stderr": result.stderr,
94
+ "tool": "hypothesis",
95
+ "note": "Fallback from CrossHair",
96
+ }
97
+ )
98
+ except subprocess.TimeoutExpired:
99
+ return Failure("Hypothesis timeout (300s)")
100
+ except Exception as e:
101
+ return Failure(f"Hypothesis error: {e}")
102
+
103
+
104
+ def run_prove_with_fallback(
105
+ files: list[Path],
106
+ crosshair_timeout: int = 10,
107
+ hypothesis_max_examples: int = 100,
108
+ use_cache: bool = True,
109
+ cache_dir: Path | None = None,
110
+ ) -> Result[dict, str]:
111
+ """
112
+ Run proof verification with automatic Hypothesis fallback.
113
+
114
+ DX-12 + DX-13: Tries CrossHair first with optimizations, falls back to Hypothesis.
115
+
116
+ Args:
117
+ files: List of Python file paths to verify
118
+ crosshair_timeout: Ignored (kept for backwards compatibility)
119
+ hypothesis_max_examples: Maximum Hypothesis examples
120
+ use_cache: Whether to use verification cache (DX-13)
121
+ cache_dir: Cache directory (default: .invar/cache/prove)
122
+
123
+ Returns:
124
+ Success with verification results or Failure with error message
125
+ """
126
+ # Import here to avoid circular import
127
+ from invar.shell.prove import CrossHairStatus, run_crosshair_parallel
128
+ from invar.shell.prove_cache import ProveCache
129
+
130
+ # DX-13: Initialize cache
131
+ cache = None
132
+ if use_cache:
133
+ if cache_dir is None:
134
+ cache_dir = Path(".invar/cache/prove")
135
+ cache = ProveCache(cache_dir=cache_dir)
136
+
137
+ # DX-13: Use parallel CrossHair with caching
138
+ crosshair_result = run_crosshair_parallel(
139
+ files,
140
+ max_iterations=5, # Fast mode
141
+ max_workers=None, # Auto-detect
142
+ cache=cache,
143
+ )
144
+
145
+ if isinstance(crosshair_result, Failure):
146
+ # CrossHair failed, try Hypothesis
147
+ return run_hypothesis_fallback(files, max_examples=hypothesis_max_examples)
148
+
149
+ result_data = crosshair_result.unwrap()
150
+ status = result_data.get("status", "")
151
+
152
+ # Check if we need fallback
153
+ needs_fallback = (
154
+ status == CrossHairStatus.SKIPPED
155
+ or status == CrossHairStatus.TIMEOUT
156
+ or "not installed" in result_data.get("reason", "")
157
+ )
158
+
159
+ if needs_fallback:
160
+ # Run Hypothesis as fallback
161
+ hypothesis_result = run_hypothesis_fallback(
162
+ files, max_examples=hypothesis_max_examples
163
+ )
164
+
165
+ if isinstance(hypothesis_result, Success):
166
+ hyp_data = hypothesis_result.unwrap()
167
+ # Merge results
168
+ return Success(
169
+ {
170
+ "status": hyp_data.get("status", "unknown"),
171
+ "primary_tool": "hypothesis",
172
+ "crosshair_status": status,
173
+ "crosshair_reason": result_data.get("reason", ""),
174
+ "hypothesis_result": hyp_data,
175
+ "files": [str(f) for f in files],
176
+ "note": "CrossHair skipped/unavailable, used Hypothesis fallback",
177
+ }
178
+ )
179
+ return hypothesis_result
180
+
181
+ # CrossHair succeeded (verified or found counterexample)
182
+ result_data["primary_tool"] = "crosshair"
183
+ return Success(result_data)
@@ -0,0 +1,443 @@
1
+ """
2
+ Template management for invar init.
3
+
4
+ Shell module: handles file I/O for template operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.resources as resources
10
+ from pathlib import Path
11
+
12
+ from returns.result import Failure, Result, Success
13
+
14
+ _DEFAULT_PYPROJECT_CONFIG = """\n# Invar Configuration
15
+ [tool.invar.guard]
16
+ core_paths = ["src/core"]
17
+ shell_paths = ["src/shell"]
18
+ max_file_lines = 300
19
+ max_function_lines = 50
20
+ require_contracts = true
21
+ require_doctests = true
22
+ forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
23
+ exclude_paths = ["tests", "scripts", ".venv"]
24
+ """
25
+
26
+ _DEFAULT_INVAR_TOML = """# Invar Configuration
27
+ # For projects without pyproject.toml
28
+
29
+ [guard]
30
+ core_paths = ["src/core"]
31
+ shell_paths = ["src/shell"]
32
+ max_file_lines = 300
33
+ max_function_lines = 50
34
+ require_contracts = true
35
+ require_doctests = true
36
+ forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
37
+ exclude_paths = ["tests", "scripts", ".venv"]
38
+
39
+ # Pattern-based classification (optional, takes priority over paths)
40
+ # core_patterns = ["**/domain/**", "**/models/**"]
41
+ # shell_patterns = ["**/api/**", "**/cli/**"]
42
+ """
43
+
44
+
45
+ def get_template_path(name: str) -> Result[Path, str]:
46
+ """Get path to a template file."""
47
+ try:
48
+ path = Path(str(resources.files("invar.templates").joinpath(name)))
49
+ if not path.exists():
50
+ return Failure(f"Template '{name}' not found")
51
+ return Success(path)
52
+ except Exception as e:
53
+ return Failure(f"Failed to get template path: {e}")
54
+
55
+
56
+ def copy_template(
57
+ template_name: str, dest: Path, dest_name: str | None = None
58
+ ) -> Result[bool, str]:
59
+ """Copy a template file to destination. Returns Success(True) if copied, Success(False) if skipped."""
60
+ if dest_name is None:
61
+ dest_name = template_name.replace(".template", "")
62
+ dest_file = dest / dest_name
63
+ if dest_file.exists():
64
+ return Success(False)
65
+ template_result = get_template_path(template_name)
66
+ if isinstance(template_result, Failure):
67
+ return template_result
68
+ template_path = template_result.unwrap()
69
+ try:
70
+ dest_file.write_text(template_path.read_text())
71
+ return Success(True)
72
+ except OSError as e:
73
+ return Failure(f"Failed to copy template: {e}")
74
+
75
+
76
+ def add_config(path: Path, console) -> Result[bool, str]:
77
+ """Add configuration to project. Returns Success(True) if added, Success(False) if skipped."""
78
+ pyproject = path / "pyproject.toml"
79
+ invar_toml = path / "invar.toml"
80
+
81
+ try:
82
+ if pyproject.exists():
83
+ content = pyproject.read_text()
84
+ if "[tool.invar]" not in content:
85
+ with pyproject.open("a") as f:
86
+ f.write(_DEFAULT_PYPROJECT_CONFIG)
87
+ console.print("[green]Added[/green] [tool.invar.guard] to pyproject.toml")
88
+ return Success(True)
89
+ return Success(False)
90
+
91
+ if not invar_toml.exists():
92
+ invar_toml.write_text(_DEFAULT_INVAR_TOML)
93
+ console.print("[green]Created[/green] invar.toml")
94
+ return Success(True)
95
+
96
+ return Success(False)
97
+ except OSError as e:
98
+ return Failure(f"Failed to add config: {e}")
99
+
100
+
101
+ def create_directories(path: Path, console) -> None:
102
+ """Create src/core and src/shell directories."""
103
+ core_path = path / "src" / "core"
104
+ shell_path = path / "src" / "shell"
105
+
106
+ if not core_path.exists():
107
+ core_path.mkdir(parents=True)
108
+ (core_path / "__init__.py").touch()
109
+ console.print("[green]Created[/green] src/core/")
110
+
111
+ if not shell_path.exists():
112
+ shell_path.mkdir(parents=True)
113
+ (shell_path / "__init__.py").touch()
114
+ console.print("[green]Created[/green] src/shell/")
115
+
116
+
117
+ def copy_examples_directory(dest: Path, console) -> Result[bool, str]:
118
+ """Copy examples directory to .invar/examples/. Returns Success(True) if copied."""
119
+ import shutil
120
+
121
+ examples_dest = dest / ".invar" / "examples"
122
+ if examples_dest.exists():
123
+ return Success(False)
124
+
125
+ try:
126
+ examples_src = Path(str(resources.files("invar.templates").joinpath("examples")))
127
+ if not examples_src.exists():
128
+ return Failure("Examples template directory not found")
129
+
130
+ # Create .invar if needed
131
+ invar_dir = dest / ".invar"
132
+ if not invar_dir.exists():
133
+ invar_dir.mkdir()
134
+
135
+ shutil.copytree(examples_src, examples_dest)
136
+ console.print("[green]Created[/green] .invar/examples/ (reference examples)")
137
+ return Success(True)
138
+ except OSError as e:
139
+ return Failure(f"Failed to copy examples: {e}")
140
+
141
+
142
+ # Agent configuration for multi-agent support (DX-11, DX-17)
143
+ AGENT_CONFIGS = {
144
+ "claude": {
145
+ "file": "CLAUDE.md",
146
+ "template": "CLAUDE.md.template",
147
+ "reference": '> **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.\n',
148
+ "check_pattern": "INVAR.md",
149
+ },
150
+ "cursor": {
151
+ "file": ".cursorrules",
152
+ "template": "cursorrules.template",
153
+ "reference": "Follow the Invar Protocol in INVAR.md.\n\n",
154
+ "check_pattern": "INVAR.md",
155
+ },
156
+ "aider": {
157
+ "file": ".aider.conf.yml",
158
+ "template": "aider.conf.yml.template",
159
+ "reference": "# Follow the Invar Protocol in INVAR.md\nread:\n - INVAR.md\n",
160
+ "check_pattern": "INVAR.md",
161
+ },
162
+ }
163
+
164
+
165
+ def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
166
+ """
167
+ Detect existing agent configuration files.
168
+
169
+ Returns dict of agent -> status where status is one of:
170
+ - "configured": File exists and contains Invar reference
171
+ - "found": File exists but no Invar reference
172
+ - "not_found": File does not exist
173
+
174
+ >>> from pathlib import Path
175
+ >>> import tempfile
176
+ >>> with tempfile.TemporaryDirectory() as tmp:
177
+ ... result = detect_agent_configs(Path(tmp))
178
+ ... result.unwrap()["claude"]
179
+ 'not_found'
180
+ """
181
+ try:
182
+ results = {}
183
+ for agent, config in AGENT_CONFIGS.items():
184
+ config_path = path / config["file"]
185
+ if config_path.exists():
186
+ content = config_path.read_text()
187
+ if config["check_pattern"] in content:
188
+ results[agent] = "configured"
189
+ else:
190
+ results[agent] = "found"
191
+ else:
192
+ results[agent] = "not_found"
193
+ return Success(results)
194
+ except OSError as e:
195
+ return Failure(f"Failed to detect agent configs: {e}")
196
+
197
+
198
+ def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
199
+ """Add Invar reference to an existing agent config file."""
200
+ if agent not in AGENT_CONFIGS:
201
+ return Failure(f"Unknown agent: {agent}")
202
+
203
+ config = AGENT_CONFIGS[agent]
204
+ config_path = path / config["file"]
205
+
206
+ if not config_path.exists():
207
+ return Failure(f"Config file not found: {config['file']}")
208
+
209
+ try:
210
+ content = config_path.read_text()
211
+ if config["check_pattern"] in content:
212
+ return Success(False) # Already configured
213
+
214
+ # Prepend reference
215
+ new_content = config["reference"] + content
216
+ config_path.write_text(new_content)
217
+ console.print(f"[green]Updated[/green] {config['file']} (added Invar reference)")
218
+ return Success(True)
219
+ except OSError as e:
220
+ return Failure(f"Failed to update {config['file']}: {e}")
221
+
222
+
223
+ def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
224
+ """
225
+ Create agent config from template (DX-17).
226
+
227
+ Creates full template file for agents that don't have an existing config.
228
+ """
229
+ if agent not in AGENT_CONFIGS:
230
+ return Failure(f"Unknown agent: {agent}")
231
+
232
+ config = AGENT_CONFIGS[agent]
233
+ config_path = path / config["file"]
234
+
235
+ if config_path.exists():
236
+ return Success(False) # Already exists
237
+
238
+ # Use template if available
239
+ template_name = config.get("template")
240
+ if template_name:
241
+ result = copy_template(template_name, path, config["file"])
242
+ if isinstance(result, Success) and result.unwrap():
243
+ console.print(f"[green]Created[/green] {config['file']} (Invar workflow enforcement)")
244
+ return Success(True)
245
+ elif isinstance(result, Failure):
246
+ return result
247
+
248
+ return Success(False)
249
+
250
+
251
+ def configure_mcp_server(path: Path, console) -> Result[list[str], str]:
252
+ """
253
+ Configure MCP server for AI agents (DX-16).
254
+
255
+ Creates:
256
+ - .invar/mcp-server.json (universal config)
257
+ - .invar/mcp-setup.md (manual setup instructions)
258
+ - Updates .claude/settings.json if .claude/ exists
259
+
260
+ Returns list of configured agents.
261
+ """
262
+ import json
263
+
264
+ configured: list[str] = []
265
+ invar_dir = path / ".invar"
266
+
267
+ # Ensure .invar exists
268
+ if not invar_dir.exists():
269
+ invar_dir.mkdir()
270
+
271
+ # MCP config using current Python (the one that has invar installed)
272
+ import sys
273
+
274
+ mcp_config = {
275
+ "name": "invar",
276
+ "command": sys.executable,
277
+ "args": ["-m", "invar.mcp"],
278
+ }
279
+
280
+ # 1. Create .mcp.json at project root (Claude Code standard)
281
+ mcp_json_path = path / ".mcp.json"
282
+ if not mcp_json_path.exists():
283
+ mcp_json_content = {
284
+ "mcpServers": {
285
+ "invar": {
286
+ "command": mcp_config["command"],
287
+ "args": mcp_config["args"],
288
+ }
289
+ }
290
+ }
291
+ mcp_json_path.write_text(json.dumps(mcp_json_content, indent=2))
292
+ console.print("[green]Created[/green] .mcp.json (MCP server config)")
293
+ configured.append("Claude Code")
294
+ else:
295
+ # Check if invar is already configured
296
+ try:
297
+ existing = json.loads(mcp_json_path.read_text())
298
+ if "mcpServers" in existing and "invar" in existing.get("mcpServers", {}):
299
+ console.print("[dim]Skipped[/dim] .mcp.json (invar already configured)")
300
+ else:
301
+ # Add invar to existing config
302
+ if "mcpServers" not in existing:
303
+ existing["mcpServers"] = {}
304
+ existing["mcpServers"]["invar"] = {
305
+ "command": mcp_config["command"],
306
+ "args": mcp_config["args"],
307
+ }
308
+ mcp_json_path.write_text(json.dumps(existing, indent=2))
309
+ console.print("[green]Updated[/green] .mcp.json (added invar)")
310
+ configured.append("Claude Code")
311
+ except (OSError, json.JSONDecodeError):
312
+ console.print("[yellow]Warning[/yellow] .mcp.json exists but couldn't update")
313
+
314
+ # 2. Create setup instructions (for reference)
315
+ mcp_setup = invar_dir / "mcp-setup.md"
316
+ if not mcp_setup.exists():
317
+ mcp_setup.write_text(_MCP_SETUP_TEMPLATE)
318
+ console.print("[green]Created[/green] .invar/mcp-setup.md (setup guide)")
319
+
320
+ return Success(configured)
321
+
322
+
323
+ _MCP_SETUP_TEMPLATE = """\
324
+ # Invar MCP Server Setup
325
+
326
+ This project includes an MCP server that provides Invar tools to AI agents.
327
+
328
+ ## Available Tools
329
+
330
+ | Tool | Replaces | Purpose |
331
+ |------|----------|---------|
332
+ | `invar_guard` | `pytest`, `crosshair` | Smart Guard verification |
333
+ | `invar_sig` | `Read` entire file | Show contracts and signatures |
334
+ | `invar_map` | `Grep` for functions | Symbol map with reference counts |
335
+
336
+ ## Configuration
337
+
338
+ `invar init` automatically creates `.mcp.json` with smart detection of available methods.
339
+
340
+ ### Recommended: uvx (isolated environment)
341
+
342
+ ```json
343
+ {
344
+ "mcpServers": {
345
+ "invar": {
346
+ "command": "uvx",
347
+ "args": ["invar-tools", "mcp"]
348
+ }
349
+ }
350
+ }
351
+ ```
352
+
353
+ ### Alternative: invar command
354
+
355
+ ```json
356
+ {
357
+ "mcpServers": {
358
+ "invar": {
359
+ "command": "invar",
360
+ "args": ["mcp"]
361
+ }
362
+ }
363
+ }
364
+ ```
365
+
366
+ ### Fallback: Python path
367
+
368
+ ```json
369
+ {
370
+ "mcpServers": {
371
+ "invar": {
372
+ "command": "/path/to/your/.venv/bin/python",
373
+ "args": ["-m", "invar.mcp"]
374
+ }
375
+ }
376
+ }
377
+ ```
378
+
379
+ Find your Python path: `python -c "import sys; print(sys.executable)"`
380
+
381
+ ## Installation
382
+
383
+ ```bash
384
+ # Recommended: use uvx (no installation needed)
385
+ uvx invar-tools guard
386
+
387
+ # Or install globally
388
+ pip install invar-tools
389
+
390
+ # Or install in project
391
+ pip install invar-tools
392
+ ```
393
+
394
+ ## Testing
395
+
396
+ Run the MCP server directly:
397
+
398
+ ```bash
399
+ # Using uvx
400
+ uvx invar-tools mcp
401
+
402
+ # Or if installed
403
+ invar mcp
404
+ ```
405
+
406
+ The server communicates via stdio and should be managed by your AI agent.
407
+ """
408
+
409
+
410
+ def install_hooks(path: Path, console) -> Result[bool, str]:
411
+ """Install pre-commit hooks configuration and activate them."""
412
+ import subprocess
413
+
414
+ pre_commit_config = path / ".pre-commit-config.yaml"
415
+
416
+ if pre_commit_config.exists():
417
+ console.print("[yellow]Skipped[/yellow] .pre-commit-config.yaml (already exists)")
418
+ return Success(False)
419
+
420
+ result = copy_template("pre-commit-config.yaml.template", path, ".pre-commit-config.yaml")
421
+ if isinstance(result, Failure):
422
+ return result
423
+
424
+ if result.unwrap():
425
+ console.print("[green]Created[/green] .pre-commit-config.yaml")
426
+
427
+ # Auto-install hooks (Automatic > Opt-in)
428
+ try:
429
+ subprocess.run(
430
+ ["pre-commit", "install"],
431
+ cwd=path,
432
+ check=True,
433
+ capture_output=True,
434
+ )
435
+ console.print("[green]Installed[/green] pre-commit hooks")
436
+ except FileNotFoundError:
437
+ console.print("[dim]Run: pre-commit install (pre-commit not in PATH)[/dim]")
438
+ except subprocess.CalledProcessError:
439
+ console.print("[dim]Run: pre-commit install (not a git repo?)[/dim]")
440
+
441
+ return Success(True)
442
+
443
+ return Success(False)