invar-tools 1.7.0__py3-none-any.whl → 1.8.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.
@@ -15,6 +15,8 @@ from pathlib import Path
15
15
  import typer
16
16
  from rich.console import Console
17
17
 
18
+ from invar.shell.claude_hooks import is_invar_hook
19
+
18
20
  console = Console()
19
21
 
20
22
 
@@ -52,9 +54,42 @@ def has_invar_hook_marker(path: Path) -> bool:
52
54
  return False
53
55
 
54
56
 
57
+ # @shell_orchestration: Regex patterns tightly coupled to file removal logic
58
+ def _is_empty_user_region(content: str) -> bool:
59
+ """Check if user region only contains template comments (no real user content)."""
60
+ # Extract user region content
61
+ match = re.search(r"<!--invar:user-->(.*?)<!--/invar:user-->", content, flags=re.DOTALL)
62
+ if not match:
63
+ return True # No user region = empty
64
+
65
+ user_content = match.group(1)
66
+
67
+ # Remove all HTML/markdown comments
68
+ cleaned = re.sub(r"<!--.*?-->", "", user_content, flags=re.DOTALL)
69
+
70
+ # Remove invar-generated merge markers and headers
71
+ invar_patterns = [
72
+ r"## Claude Analysis \(Preserved\)\s*",
73
+ r"## My Custom Rules\s*",
74
+ r"- Rule \d+:.*\n?", # Template rules
75
+ ]
76
+ for pattern in invar_patterns:
77
+ cleaned = re.sub(pattern, "", cleaned)
78
+
79
+ # Remove whitespace
80
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
81
+
82
+ # If only whitespace or empty after removing comments and invar content, it's "empty"
83
+ return len(cleaned) == 0
84
+
85
+
55
86
  # @shell_orchestration: Regex patterns tightly coupled to file removal logic
56
87
  def remove_invar_regions(content: str) -> str:
57
- """Remove <!--invar:xxx-->...<!--/invar:xxx--> regions except user region."""
88
+ """Remove <!--invar:xxx-->...<!--/invar:xxx--> regions.
89
+
90
+ User region is also removed if it only contains template comments.
91
+ Merge markers are always cleaned from user region.
92
+ """
58
93
  patterns = [
59
94
  # HTML-style regions (CLAUDE.md)
60
95
  (r"<!--invar:critical-->.*?<!--/invar:critical-->\n?", ""),
@@ -63,8 +98,34 @@ def remove_invar_regions(content: str) -> str:
63
98
  # Comment-style regions (.pre-commit-config.yaml)
64
99
  (r"# invar:begin\n.*?# invar:end\n?", ""),
65
100
  ]
101
+
102
+ # Also remove empty user region (only has template comments)
103
+ if _is_empty_user_region(content):
104
+ patterns.append((r"<!--invar:user-->.*?<!--/invar:user-->\n?", ""))
105
+ else:
106
+ # User region has real content - just remove the markers but keep content
107
+ patterns.append((r"<!--invar:user-->\n?", ""))
108
+ patterns.append((r"<!--/invar:user-->\n?", ""))
109
+ # Also clean invar-generated merge markers from user content
110
+ patterns.extend([
111
+ (r"<!-- =+ -->\n?", ""),
112
+ (r"<!-- MERGED CONTENT.*?-->\n?", ""),
113
+ (r"<!-- Original source:.*?-->\n?", ""),
114
+ (r"<!-- Merge date:.*?-->\n?", ""),
115
+ (r"<!-- END MERGED CONTENT -->\n?", ""),
116
+ (r"<!-- =+ -->\n?", ""),
117
+ (r"## Claude Analysis \(Preserved\)\n*", ""),
118
+ ])
119
+
66
120
  for pattern, replacement in patterns:
67
121
  content = re.sub(pattern, replacement, content, flags=re.DOTALL)
122
+
123
+ # Clean up trailing footer if nothing else left
124
+ content = re.sub(r"\n*---\n+\*Generated by.*?\*\s*$", "", content, flags=re.DOTALL)
125
+
126
+ # Clean up multiple blank lines
127
+ content = re.sub(r"\n{3,}", "\n\n", content)
128
+
68
129
  return content.strip()
69
130
 
70
131
 
@@ -84,6 +145,59 @@ def remove_mcp_invar_entry(path: Path) -> tuple[bool, str]:
84
145
  return False, ""
85
146
 
86
147
 
148
+ # @shell_complexity: JSON parsing with conditional cleanup logic
149
+ def remove_hooks_from_settings(path: Path) -> tuple[bool, str]:
150
+ """Remove Invar hooks from .claude/settings.local.json.
151
+
152
+ Uses merge strategy:
153
+ - Only removes Invar hooks (identified by .claude/hooks/ path)
154
+ - Preserves user's existing hooks
155
+ - Cleans up empty hook types and hooks section
156
+ """
157
+ settings_path = path / ".claude" / "settings.local.json"
158
+
159
+ try:
160
+ if not settings_path.exists():
161
+ return False, ""
162
+ content = settings_path.read_text()
163
+ data = json.loads(content)
164
+
165
+ if "hooks" not in data:
166
+ return False, content
167
+
168
+ existing_hooks = data["hooks"]
169
+ modified = False
170
+
171
+ # Filter out Invar hooks from each hook type
172
+ for hook_type in list(existing_hooks.keys()):
173
+ hook_list = existing_hooks[hook_type]
174
+ if isinstance(hook_list, list):
175
+ # Keep only non-Invar hooks
176
+ filtered = [h for h in hook_list if not is_invar_hook(h)]
177
+ if len(filtered) != len(hook_list):
178
+ modified = True
179
+ if filtered:
180
+ existing_hooks[hook_type] = filtered
181
+ else:
182
+ # No hooks left for this type, remove the key
183
+ del existing_hooks[hook_type]
184
+
185
+ # If no hooks left, remove the hooks section entirely
186
+ if not existing_hooks:
187
+ del data["hooks"]
188
+
189
+ if not modified:
190
+ return False, content
191
+
192
+ # If nothing left in data, indicate file can be deleted
193
+ if not data:
194
+ return True, ""
195
+
196
+ return True, json.dumps(data, indent=2)
197
+ except (OSError, json.JSONDecodeError):
198
+ return False, ""
199
+
200
+
87
201
  # @shell_complexity: Multi-file type detection requires comprehensive branching
88
202
  def collect_removal_targets(path: Path) -> dict:
89
203
  """Collect files and directories to remove/modify."""
@@ -146,16 +260,37 @@ def collect_removal_targets(path: Path) -> dict:
146
260
  (f".claude/hooks/{hook_file.name}", "hook, has invar marker")
147
261
  )
148
262
 
149
- # CLAUDE.md - modify, not delete
263
+ # Pi hooks (LX-04)
264
+ pi_hooks_dir = path / ".pi" / "hooks"
265
+ if pi_hooks_dir.exists():
266
+ invar_ts = pi_hooks_dir / "invar.ts"
267
+ if invar_ts.exists():
268
+ targets["delete_files"].append((".pi/hooks/invar.ts", "Pi hook"))
269
+ # Check if .pi/hooks is empty after removal
270
+ if not any(f for f in pi_hooks_dir.iterdir() if f.name != "invar.ts"):
271
+ targets["delete_dirs"].append((".pi/hooks/", "empty after removal"))
272
+ # Check if .pi is empty
273
+ pi_dir = path / ".pi"
274
+ hooks_only = all(
275
+ child.name == "hooks" for child in pi_dir.iterdir() if child.is_dir()
276
+ )
277
+ if hooks_only:
278
+ targets["delete_dirs"].append((".pi/", "only had hooks"))
279
+
280
+ # CLAUDE.md - delete if empty user region, otherwise modify
150
281
  claude_md = path / "CLAUDE.md"
151
282
  if claude_md.exists():
152
283
  content = claude_md.read_text()
153
284
  if "<!--invar:" in content:
154
- # Check if there's user content
155
- has_user_region = "<!--invar:user-->" in content
156
- targets["modify_files"].append(
157
- ("CLAUDE.md", f"remove invar regions{', keep user region' if has_user_region else ''}")
158
- )
285
+ # Check if user region has real content
286
+ if _is_empty_user_region(content):
287
+ # Will be empty after cleanup - delete
288
+ targets["delete_files"].append(("CLAUDE.md", "no user content"))
289
+ else:
290
+ # Has user content - modify
291
+ targets["modify_files"].append(
292
+ ("CLAUDE.md", "remove invar regions, keep user content")
293
+ )
159
294
 
160
295
  # .mcp.json - modify or delete
161
296
  mcp_json = path / ".mcp.json"
@@ -167,6 +302,20 @@ def collect_removal_targets(path: Path) -> dict:
167
302
  else:
168
303
  targets["delete_files"].append((".mcp.json", "only had invar config"))
169
304
 
305
+ # settings.local.json - remove hooks section or delete if empty
306
+ settings_local = path / ".claude" / "settings.local.json"
307
+ if settings_local.exists():
308
+ modified, new_content = remove_hooks_from_settings(path)
309
+ if modified:
310
+ if new_content:
311
+ targets["modify_files"].append(
312
+ (".claude/settings.local.json", "remove hooks section")
313
+ )
314
+ else:
315
+ targets["delete_files"].append(
316
+ (".claude/settings.local.json", "only had hooks config")
317
+ )
318
+
170
319
  # Config files with region markers (DX-69: cursor/aider removed)
171
320
  for file_name in [".pre-commit-config.yaml"]:
172
321
  file_path = path / file_name
@@ -245,6 +394,11 @@ def execute_removal(path: Path, targets: dict) -> None:
245
394
  if modified and new_content:
246
395
  file_path.write_text(new_content)
247
396
  console.print(f"[yellow]Modified[/yellow] {file_name}")
397
+ elif file_name == ".claude/settings.local.json":
398
+ modified, new_content = remove_hooks_from_settings(path)
399
+ if modified and new_content:
400
+ file_path.write_text(new_content)
401
+ console.print(f"[yellow]Modified[/yellow] {file_name}")
248
402
  else:
249
403
  content = file_path.read_text()
250
404
  cleaned = remove_invar_regions(content)
@@ -270,6 +424,7 @@ def execute_removal(path: Path, targets: dict) -> None:
270
424
  console.print("[dim]Removed empty[/dim] .claude/")
271
425
 
272
426
 
427
+ # @shell_complexity: CLI entry point with confirmation prompts and multi-target removal
273
428
  def uninstall(
274
429
  path: Path = typer.Argument(
275
430
  Path(),
@@ -121,6 +121,7 @@ def count_contracts_in_file(
121
121
  return Success(result)
122
122
 
123
123
 
124
+ # @shell_complexity: Git status parsing requires multiple branch conditions
124
125
  def get_changed_python_files(path: Path) -> Result[list[Path], str]:
125
126
  """Get Python files changed in git."""
126
127
  try:
@@ -153,6 +154,7 @@ def get_changed_python_files(path: Path) -> Result[list[Path], str]:
153
154
  return Failure("Git not found")
154
155
 
155
156
 
157
+ # @shell_complexity: Coverage calculation with multiple file/directory handling paths
156
158
  def calculate_contract_coverage(
157
159
  path: Path, changed_only: bool = False
158
160
  ) -> Result[ContractCoverageReport, str]:
@@ -207,6 +209,7 @@ def calculate_contract_coverage(
207
209
  return Success(report)
208
210
 
209
211
 
212
+ # @shell_complexity: Batch detection with git status parsing and threshold logic
210
213
  def detect_batch_creation(
211
214
  path: Path, threshold: int = 3
212
215
  ) -> Result[BatchWarning | None, str]:
@@ -263,7 +266,7 @@ def detect_batch_creation(
263
266
  return Success(None)
264
267
 
265
268
 
266
- # @shell_orchestration: Report formatting tightly coupled with CLI output
269
+ # @shell_complexity: Report formatting with multiple conditional sections
267
270
  def format_contract_coverage_report(report: ContractCoverageReport) -> str:
268
271
  """Format coverage report for human-readable output."""
269
272
  lines = [
@@ -0,0 +1,207 @@
1
+ """
2
+ Pi Coding Agent hooks for Invar.
3
+
4
+ LX-04: Full feature parity with Claude Code hooks.
5
+ - pytest/crosshair blocking via tool_call
6
+ - Protocol injection via pi.send() for long conversations
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from jinja2 import Environment, FileSystemLoader
17
+ from returns.result import Failure, Result, Success
18
+
19
+ from invar.core.template_helpers import escape_for_js_template
20
+ from invar.shell.claude_hooks import detect_syntax, get_invar_md_content
21
+
22
+ if TYPE_CHECKING:
23
+ from rich.console import Console
24
+
25
+ # Pi hooks directory
26
+ PI_HOOKS_DIR = ".pi/hooks"
27
+ PROTOCOL_VERSION = "5.0"
28
+
29
+
30
+ def get_pi_templates_path() -> Path:
31
+ """Get the path to Pi hook templates."""
32
+ return Path(__file__).parent.parent / "templates" / "hooks" / "pi"
33
+
34
+
35
+ # @shell_complexity: Template rendering with protocol escaping
36
+ def generate_pi_hook_content(project_path: Path) -> Result[str, str]:
37
+ """Generate Pi hook content from template."""
38
+ templates_path = get_pi_templates_path()
39
+ template_file = "invar.ts.jinja"
40
+
41
+ if not (templates_path / template_file).exists():
42
+ return Failure(f"Template not found: {template_file}")
43
+
44
+ try:
45
+ env = Environment(
46
+ loader=FileSystemLoader(str(templates_path)),
47
+ keep_trailing_newline=True,
48
+ )
49
+ template = env.get_template(template_file)
50
+
51
+ # Determine guard command based on syntax
52
+ syntax = detect_syntax(project_path)
53
+ guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
54
+
55
+ # Get and escape protocol content for JS template literal
56
+ protocol_content = get_invar_md_content(project_path)
57
+ protocol_escaped = escape_for_js_template(protocol_content)
58
+
59
+ # Build context for template
60
+ context = {
61
+ "protocol_version": PROTOCOL_VERSION,
62
+ "generated_date": datetime.now().strftime("%Y-%m-%d"),
63
+ "guard_cmd": guard_cmd,
64
+ "invar_protocol_escaped": protocol_escaped,
65
+ }
66
+
67
+ content = template.render(**context)
68
+ return Success(content)
69
+ except Exception as e:
70
+ return Failure(f"Failed to generate Pi hook: {e}")
71
+
72
+
73
+ def install_pi_hooks(
74
+ project_path: Path,
75
+ console: Console,
76
+ ) -> Result[list[str], str]:
77
+ """
78
+ Install Pi hooks for Invar.
79
+
80
+ Creates .pi/hooks/invar.ts with:
81
+ - pytest/crosshair blocking
82
+ - Protocol injection for long conversations
83
+ """
84
+ hooks_dir = project_path / PI_HOOKS_DIR
85
+ hooks_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ console.print("\n[bold]Installing Pi hooks (LX-04)...[/bold]")
88
+ console.print(" Hooks will:")
89
+ console.print(" ✓ Block pytest/crosshair → redirect to invar guard")
90
+ console.print(" ✓ Refresh protocol in long conversations")
91
+ console.print("")
92
+
93
+ result = generate_pi_hook_content(project_path)
94
+ if isinstance(result, Failure):
95
+ console.print(f" [red]Failed:[/red] {result.failure()}")
96
+ return Failure(result.failure())
97
+
98
+ content = result.unwrap()
99
+ hook_file = hooks_dir / "invar.ts"
100
+ hook_file.write_text(content)
101
+
102
+ console.print(f" [green]Created[/green] {PI_HOOKS_DIR}/invar.ts")
103
+ console.print("\n [bold green]✓ Pi hooks installed[/bold green]")
104
+ console.print(" [dim]Requires: Pi coding agent with hooks support[/dim]")
105
+ console.print(" [yellow]⚠ Restart Pi session for hooks to take effect[/yellow]")
106
+
107
+ return Success(["invar.ts"])
108
+
109
+
110
+ # @shell_complexity: Version detection and conditional update logic
111
+ def sync_pi_hooks(
112
+ project_path: Path,
113
+ console: Console,
114
+ ) -> Result[list[str], str]:
115
+ """
116
+ Update Pi hooks with current INVAR.md content.
117
+
118
+ Called during `invar init` to ensure hooks stay in sync with protocol.
119
+ Only updates if Pi hooks are already installed.
120
+ """
121
+ hooks_dir = project_path / PI_HOOKS_DIR
122
+ hook_file = hooks_dir / "invar.ts"
123
+
124
+ if not hook_file.exists():
125
+ return Success([]) # No hooks installed, nothing to sync
126
+
127
+ # Check version in existing hook
128
+ try:
129
+ existing_content = hook_file.read_text()
130
+ version_match = re.search(r"Protocol: v([\d.]+)", existing_content)
131
+ old_version = version_match.group(1) if version_match else "unknown"
132
+
133
+ if old_version != PROTOCOL_VERSION:
134
+ console.print(f"[cyan]Updating Pi hooks: v{old_version} → v{PROTOCOL_VERSION}[/cyan]")
135
+ else:
136
+ console.print("[dim]Refreshing Pi hooks...[/dim]")
137
+ except OSError:
138
+ pass
139
+
140
+ result = generate_pi_hook_content(project_path)
141
+ if isinstance(result, Failure):
142
+ console.print(f" [yellow]Warning:[/yellow] Failed to generate Pi hook: {result.failure()}")
143
+ return Failure(result.failure())
144
+
145
+ content = result.unwrap()
146
+ hook_file.write_text(content)
147
+ console.print("[green]✓[/green] Pi hooks synced")
148
+
149
+ return Success(["invar.ts"])
150
+
151
+
152
+ def remove_pi_hooks(
153
+ project_path: Path,
154
+ console: Console,
155
+ ) -> Result[None, str]:
156
+ """Remove Pi hooks."""
157
+ hooks_dir = project_path / PI_HOOKS_DIR
158
+ hook_file = hooks_dir / "invar.ts"
159
+
160
+ if hook_file.exists():
161
+ hook_file.unlink()
162
+ console.print(f" [red]Removed[/red] {PI_HOOKS_DIR}/invar.ts")
163
+
164
+ # Remove directory if empty
165
+ try:
166
+ hooks_dir.rmdir()
167
+ console.print(f" [red]Removed[/red] {PI_HOOKS_DIR}/")
168
+ except OSError:
169
+ pass # Directory not empty, keep it
170
+
171
+ console.print("[bold green]✓ Pi hooks removed[/bold green]")
172
+ else:
173
+ console.print("[dim]No Pi hooks installed[/dim]")
174
+
175
+ return Success(None)
176
+
177
+
178
+ def pi_hooks_status(
179
+ project_path: Path,
180
+ console: Console,
181
+ ) -> Result[dict[str, str], str]:
182
+ """Check status of Pi hooks."""
183
+ hooks_dir = project_path / PI_HOOKS_DIR
184
+ hook_file = hooks_dir / "invar.ts"
185
+
186
+ status: dict[str, str] = {}
187
+
188
+ if not hook_file.exists():
189
+ console.print("[dim]No Pi hooks installed[/dim]")
190
+ return Success({"status": "not_installed"})
191
+
192
+ status["status"] = "installed"
193
+
194
+ # Try to get version
195
+ try:
196
+ content = hook_file.read_text()
197
+ match = re.search(r"Protocol: v([\d.]+)", content)
198
+ if match:
199
+ version = match.group(1)
200
+ status["version"] = version
201
+ console.print(f"[green]✓ Pi hooks installed (v{version})[/green]")
202
+ else:
203
+ console.print("[green]✓ Pi hooks installed[/green]")
204
+ except OSError:
205
+ console.print("[green]✓ Pi hooks installed[/green]")
206
+
207
+ return Success(status)
invar/shell/templates.py CHANGED
@@ -76,11 +76,18 @@ def copy_template(
76
76
 
77
77
  # @shell_complexity: Config addition with existing file detection
78
78
  def add_config(path: Path, console) -> Result[bool, str]:
79
- """Add configuration to project. Returns Success(True) if added, Success(False) if skipped."""
79
+ """Add configuration to project. Returns Success(True) if added, Success(False) if skipped.
80
+
81
+ DX-70: Creates .invar/config.toml instead of invar.toml for cleaner organization.
82
+ Backward compatible: still reads from invar.toml if it exists.
83
+ """
80
84
  pyproject = path / "pyproject.toml"
81
- invar_toml = path / "invar.toml"
85
+ invar_dir = path / ".invar"
86
+ invar_config = invar_dir / "config.toml"
87
+ legacy_invar_toml = path / "invar.toml"
82
88
 
83
89
  try:
90
+ # Priority 1: Add to pyproject.toml if it exists
84
91
  if pyproject.exists():
85
92
  content = pyproject.read_text()
86
93
  if "[tool.invar]" not in content:
@@ -90,9 +97,15 @@ def add_config(path: Path, console) -> Result[bool, str]:
90
97
  return Success(True)
91
98
  return Success(False)
92
99
 
93
- if not invar_toml.exists():
94
- invar_toml.write_text(_DEFAULT_INVAR_TOML)
95
- console.print("[green]Created[/green] invar.toml")
100
+ # Skip if legacy invar.toml exists (backward compatibility)
101
+ if legacy_invar_toml.exists():
102
+ return Success(False)
103
+
104
+ # Create .invar/config.toml (DX-70: new default location)
105
+ if not invar_config.exists():
106
+ invar_dir.mkdir(exist_ok=True)
107
+ invar_config.write_text(_DEFAULT_INVAR_TOML)
108
+ console.print("[green]Created[/green] .invar/config.toml")
96
109
  return Success(True)
97
110
 
98
111
  return Success(False)
@@ -197,6 +210,7 @@ AGENT_CONFIGS = {
197
210
  }
198
211
 
199
212
 
213
+ # @shell_complexity: Multi-agent config detection with file existence checks
200
214
  def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
201
215
  """
202
216
  Detect existing agent configuration files.
@@ -392,36 +406,28 @@ The server communicates via stdio and should be managed by your AI agent.
392
406
 
393
407
  # @shell_complexity: Git hooks installation with backup
394
408
  def install_hooks(path: Path, console) -> Result[bool, str]:
395
- """Install pre-commit hooks configuration and activate them."""
409
+ """Run 'pre-commit install' if config exists (file created by sync_templates)."""
396
410
  import subprocess
397
411
 
398
412
  pre_commit_config = path / ".pre-commit-config.yaml"
399
413
 
400
- if pre_commit_config.exists():
401
- console.print("[yellow]Skipped[/yellow] .pre-commit-config.yaml (already exists)")
414
+ if not pre_commit_config.exists():
415
+ # File should be created by sync_templates; skip if missing
402
416
  return Success(False)
403
417
 
404
- result = copy_template("pre-commit-config.yaml.template", path, ".pre-commit-config.yaml")
405
- if isinstance(result, Failure):
406
- return result
407
-
408
- if result.unwrap():
409
- console.print("[green]Created[/green] .pre-commit-config.yaml")
410
-
411
- # Auto-install hooks (Automatic > Opt-in)
412
- try:
413
- subprocess.run(
414
- ["pre-commit", "install"],
415
- cwd=path,
416
- check=True,
417
- capture_output=True,
418
- )
419
- console.print("[green]Installed[/green] pre-commit hooks")
420
- except FileNotFoundError:
421
- console.print("[dim]Run: pre-commit install (pre-commit not in PATH)[/dim]")
422
- except subprocess.CalledProcessError:
423
- console.print("[dim]Run: pre-commit install (not a git repo?)[/dim]")
424
-
418
+ # Auto-install hooks (Automatic > Opt-in)
419
+ try:
420
+ subprocess.run(
421
+ ["pre-commit", "install"],
422
+ cwd=path,
423
+ check=True,
424
+ capture_output=True,
425
+ )
426
+ console.print("[green]Installed[/green] pre-commit hooks")
425
427
  return Success(True)
428
+ except FileNotFoundError:
429
+ console.print("[dim]Run: pre-commit install (pre-commit not in PATH)[/dim]")
430
+ except subprocess.CalledProcessError:
431
+ console.print("[dim]Run: pre-commit install (not a git repo?)[/dim]")
426
432
 
427
433
  return Success(False)