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.
- invar/core/template_helpers.py +32 -0
- invar/core/utils.py +3 -1
- invar/shell/claude_hooks.py +90 -0
- invar/shell/commands/init.py +348 -311
- invar/shell/commands/uninstall.py +162 -7
- invar/shell/contract_coverage.py +4 -1
- invar/shell/pi_hooks.py +207 -0
- invar/shell/templates.py +35 -29
- invar/templates/config/AGENT.md.jinja +198 -0
- invar/templates/config/pre-commit.yaml.jinja +2 -0
- invar/templates/hooks/pi/invar.ts.jinja +73 -0
- invar/templates/manifest.toml +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +59 -0
- invar/templates/skills/investigate/SKILL.md.jinja +15 -0
- invar/templates/skills/propose/SKILL.md.jinja +33 -0
- invar/templates/skills/review/SKILL.md.jinja +15 -0
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/METADATA +71 -46
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/RECORD +23 -20
- invar/templates/pre-commit-config.yaml.template +0 -46
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.7.0.dist-info → invar_tools-1.8.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
("CLAUDE.md",
|
|
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(),
|
invar/shell/contract_coverage.py
CHANGED
|
@@ -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
|
-
# @
|
|
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 = [
|
invar/shell/pi_hooks.py
ADDED
|
@@ -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
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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)
|