invar-tools 1.3.0__py3-none-any.whl → 1.3.2__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/shell/claude_hooks.py +387 -0
- invar/shell/commands/guard.py +2 -0
- invar/shell/commands/hooks.py +74 -0
- invar/shell/commands/init.py +30 -0
- invar/shell/commands/template_sync.py +42 -11
- invar/shell/commands/test.py +1 -1
- invar/shell/templates.py +2 -2
- invar/templates/CLAUDE.md.template +25 -5
- invar/templates/config/CLAUDE.md.jinja +16 -0
- invar/templates/config/context.md.jinja +11 -6
- invar/templates/context.md.template +35 -18
- invar/templates/hooks/PostToolUse.sh.jinja +102 -0
- invar/templates/hooks/PreToolUse.sh.jinja +74 -0
- invar/templates/hooks/Stop.sh.jinja +23 -0
- invar/templates/hooks/UserPromptSubmit.sh.jinja +77 -0
- invar/templates/hooks/__init__.py +1 -0
- invar/templates/manifest.toml +2 -2
- invar/templates/protocol/INVAR.md +105 -6
- invar/templates/skills/develop/SKILL.md.jinja +4 -7
- invar/templates/skills/investigate/SKILL.md.jinja +4 -7
- invar/templates/skills/propose/SKILL.md.jinja +4 -7
- invar/templates/skills/review/SKILL.md.jinja +63 -15
- invar_tools-1.3.2.dist-info/METADATA +505 -0
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/RECORD +29 -22
- invar_tools-1.3.0.dist-info/METADATA +0 -377
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/WHEEL +0 -0
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code hooks management for Invar.
|
|
3
|
+
|
|
4
|
+
DX-57: Implements hook installation, update, and management.
|
|
5
|
+
Shell module: handles file I/O for hooks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from jinja2 import Environment, FileSystemLoader
|
|
16
|
+
from returns.result import Failure, Result, Success
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
# Protocol version for hook version tracking
|
|
22
|
+
PROTOCOL_VERSION = "5.0"
|
|
23
|
+
|
|
24
|
+
# Hook types supported
|
|
25
|
+
HOOK_TYPES = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]
|
|
26
|
+
|
|
27
|
+
# Path constants
|
|
28
|
+
HOOKS_SUBDIR = ".claude/hooks"
|
|
29
|
+
DISABLED_MARKER = ".invar_disabled"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_templates_path() -> Path:
|
|
33
|
+
"""Get the path to hook templates."""
|
|
34
|
+
return Path(__file__).parent.parent / "templates" / "hooks"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_invar_md_content(project_path: Path) -> str:
|
|
38
|
+
"""Read INVAR.md content for embedding in UserPromptSubmit hook."""
|
|
39
|
+
invar_md = project_path / "INVAR.md"
|
|
40
|
+
if invar_md.exists():
|
|
41
|
+
return invar_md.read_text()
|
|
42
|
+
# Fallback to template if INVAR.md not yet created
|
|
43
|
+
template_path = Path(__file__).parent.parent / "templates" / "protocol" / "INVAR.md"
|
|
44
|
+
if template_path.exists():
|
|
45
|
+
return template_path.read_text()
|
|
46
|
+
return "# INVAR.md not found"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_syntax(project_path: Path) -> str:
|
|
50
|
+
"""Detect syntax mode from .mcp.json presence."""
|
|
51
|
+
mcp_json = project_path / ".mcp.json"
|
|
52
|
+
if mcp_json.exists():
|
|
53
|
+
try:
|
|
54
|
+
content = mcp_json.read_text()
|
|
55
|
+
if '"invar"' in content:
|
|
56
|
+
return "mcp"
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
return "cli"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# @shell_complexity: Template rendering with syntax detection
|
|
63
|
+
def generate_hook_content(
|
|
64
|
+
hook_type: str,
|
|
65
|
+
project_path: Path,
|
|
66
|
+
) -> Result[str, str]:
|
|
67
|
+
"""Generate hook content from template."""
|
|
68
|
+
templates_path = get_templates_path()
|
|
69
|
+
template_file = f"{hook_type}.sh.jinja"
|
|
70
|
+
|
|
71
|
+
if not (templates_path / template_file).exists():
|
|
72
|
+
return Failure(f"Template not found: {template_file}")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
env = Environment(
|
|
76
|
+
loader=FileSystemLoader(str(templates_path)),
|
|
77
|
+
keep_trailing_newline=True,
|
|
78
|
+
)
|
|
79
|
+
template = env.get_template(template_file)
|
|
80
|
+
|
|
81
|
+
# Determine guard command based on syntax
|
|
82
|
+
syntax = detect_syntax(project_path)
|
|
83
|
+
guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
|
|
84
|
+
|
|
85
|
+
# Build context for template
|
|
86
|
+
context = {
|
|
87
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
88
|
+
"generated_date": datetime.now().strftime("%Y-%m-%d"),
|
|
89
|
+
"guard_cmd": guard_cmd,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# For UserPromptSubmit, add the full INVAR.md content
|
|
93
|
+
if hook_type == "UserPromptSubmit":
|
|
94
|
+
context["invar_protocol"] = get_invar_md_content(project_path)
|
|
95
|
+
|
|
96
|
+
content = template.render(**context)
|
|
97
|
+
return Success(content)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return Failure(f"Failed to generate {hook_type} hook: {e}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# @shell_complexity: Hook installation with user hook merging
|
|
103
|
+
def install_claude_hooks(
|
|
104
|
+
project_path: Path,
|
|
105
|
+
console: Console,
|
|
106
|
+
) -> Result[list[str], str]:
|
|
107
|
+
"""
|
|
108
|
+
Install Claude Code hooks for Invar.
|
|
109
|
+
|
|
110
|
+
Creates:
|
|
111
|
+
- .claude/hooks/invar.{HookType}.sh - Invar hook scripts
|
|
112
|
+
- .claude/hooks/{HookType}.sh - Wrapper that sources Invar hook
|
|
113
|
+
|
|
114
|
+
Preserves existing user hooks by creating wrapper that runs both.
|
|
115
|
+
"""
|
|
116
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
117
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
installed: list[str] = []
|
|
120
|
+
failed: list[str] = []
|
|
121
|
+
|
|
122
|
+
console.print("\n[bold]Installing Claude Code hooks (DX-57)...[/bold]")
|
|
123
|
+
console.print(" Hooks will:")
|
|
124
|
+
console.print(" ✓ Block pytest/crosshair → redirect to invar_guard")
|
|
125
|
+
console.print(" ✓ Remind to verify after code changes")
|
|
126
|
+
console.print(" ✓ Refresh protocol in long conversations (~1,800 tokens)")
|
|
127
|
+
console.print("")
|
|
128
|
+
|
|
129
|
+
for hook_type in HOOK_TYPES:
|
|
130
|
+
result = generate_hook_content(hook_type, project_path)
|
|
131
|
+
if isinstance(result, Failure):
|
|
132
|
+
console.print(f" [red]Failed:[/red] {result.failure()}")
|
|
133
|
+
failed.append(hook_type)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
content = result.unwrap()
|
|
137
|
+
invar_hook = hooks_dir / f"invar.{hook_type}.sh"
|
|
138
|
+
wrapper_hook = hooks_dir / f"{hook_type}.sh"
|
|
139
|
+
|
|
140
|
+
# Write Invar hook
|
|
141
|
+
invar_hook.write_text(content)
|
|
142
|
+
invar_hook.chmod(0o755)
|
|
143
|
+
|
|
144
|
+
# Handle wrapper hook
|
|
145
|
+
if wrapper_hook.exists():
|
|
146
|
+
existing_content = wrapper_hook.read_text()
|
|
147
|
+
if f"invar.{hook_type}.sh" in existing_content:
|
|
148
|
+
# Already has Invar reference, just update invar hook
|
|
149
|
+
console.print(f" [cyan]Updated[/cyan] invar.{hook_type}.sh")
|
|
150
|
+
else:
|
|
151
|
+
# User hook exists, create backup and merge
|
|
152
|
+
backup = hooks_dir / f"{hook_type}.sh.user_backup"
|
|
153
|
+
if not backup.exists():
|
|
154
|
+
backup.write_text(existing_content)
|
|
155
|
+
console.print(f" [dim]Backed up user {hook_type}.sh[/dim]")
|
|
156
|
+
|
|
157
|
+
# Create merged wrapper
|
|
158
|
+
merged = f'''#!/bin/bash
|
|
159
|
+
# Merged hook: User + Invar (DX-57)
|
|
160
|
+
# User hooks run first (higher priority)
|
|
161
|
+
|
|
162
|
+
HOOK_DIR="$(dirname "$0")"
|
|
163
|
+
|
|
164
|
+
# Run user hook first
|
|
165
|
+
if [[ -f "$HOOK_DIR/{hook_type}.sh.user_backup" ]]; then
|
|
166
|
+
source "$HOOK_DIR/{hook_type}.sh.user_backup" "$@"
|
|
167
|
+
USER_EXIT=$?
|
|
168
|
+
[[ $USER_EXIT -ne 0 ]] && exit $USER_EXIT
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# Run Invar hook
|
|
172
|
+
if [[ -f "$HOOK_DIR/invar.{hook_type}.sh" ]]; then
|
|
173
|
+
source "$HOOK_DIR/invar.{hook_type}.sh" "$@"
|
|
174
|
+
fi
|
|
175
|
+
'''
|
|
176
|
+
wrapper_hook.write_text(merged)
|
|
177
|
+
wrapper_hook.chmod(0o755)
|
|
178
|
+
console.print(f" [green]Merged[/green] {hook_type}.sh (user hook preserved)")
|
|
179
|
+
else:
|
|
180
|
+
# No user hook, create simple wrapper
|
|
181
|
+
wrapper = f'''#!/bin/bash
|
|
182
|
+
# Invar hook wrapper (DX-57)
|
|
183
|
+
source "$(dirname "$0")/invar.{hook_type}.sh" "$@"
|
|
184
|
+
'''
|
|
185
|
+
wrapper_hook.write_text(wrapper)
|
|
186
|
+
wrapper_hook.chmod(0o755)
|
|
187
|
+
console.print(f" [green]Created[/green] {hook_type}.sh")
|
|
188
|
+
|
|
189
|
+
installed.append(hook_type)
|
|
190
|
+
|
|
191
|
+
if installed:
|
|
192
|
+
console.print("\n [bold green]✓ Claude Code hooks installed[/bold green]")
|
|
193
|
+
console.print(" [dim]Auto-escape: pytest --pdb, pytest --cov, vendor/[/dim]")
|
|
194
|
+
console.print(" [dim]Manual escape: INVAR_ALLOW_PYTEST=1[/dim]")
|
|
195
|
+
|
|
196
|
+
if failed:
|
|
197
|
+
return Failure(f"Failed to install hooks: {', '.join(failed)}")
|
|
198
|
+
|
|
199
|
+
return Success(installed)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# @shell_complexity: Hook sync with version detection
|
|
203
|
+
def sync_claude_hooks(
|
|
204
|
+
project_path: Path,
|
|
205
|
+
console: Console,
|
|
206
|
+
) -> Result[list[str], str]:
|
|
207
|
+
"""
|
|
208
|
+
Update Claude Code hooks with current INVAR.md content.
|
|
209
|
+
|
|
210
|
+
Called during `invar init` to ensure hooks stay in sync with protocol.
|
|
211
|
+
Only updates if Invar hooks are already installed.
|
|
212
|
+
"""
|
|
213
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
214
|
+
|
|
215
|
+
# Check if Invar hooks are installed
|
|
216
|
+
check_hook = hooks_dir / "invar.UserPromptSubmit.sh"
|
|
217
|
+
if not check_hook.exists():
|
|
218
|
+
return Success([]) # No hooks installed, nothing to sync
|
|
219
|
+
|
|
220
|
+
# Check version in existing hook
|
|
221
|
+
try:
|
|
222
|
+
existing_content = check_hook.read_text()
|
|
223
|
+
# Extract version from header comment
|
|
224
|
+
version_match = re.search(r"Protocol: v([\d.]+)", existing_content)
|
|
225
|
+
old_version = version_match.group(1) if version_match else "unknown"
|
|
226
|
+
|
|
227
|
+
if old_version != PROTOCOL_VERSION:
|
|
228
|
+
console.print(f"[cyan]Updating Claude hooks: v{old_version} → v{PROTOCOL_VERSION}[/cyan]")
|
|
229
|
+
else:
|
|
230
|
+
# Still update to refresh INVAR.md content
|
|
231
|
+
console.print("[dim]Refreshing Claude hooks...[/dim]")
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
updated: list[str] = []
|
|
236
|
+
failed: list[str] = []
|
|
237
|
+
|
|
238
|
+
for hook_type in HOOK_TYPES:
|
|
239
|
+
result = generate_hook_content(hook_type, project_path)
|
|
240
|
+
if isinstance(result, Failure):
|
|
241
|
+
console.print(f" [yellow]Warning:[/yellow] Failed to generate {hook_type}: {result.failure()}")
|
|
242
|
+
failed.append(hook_type)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
content = result.unwrap()
|
|
246
|
+
invar_hook = hooks_dir / f"invar.{hook_type}.sh"
|
|
247
|
+
|
|
248
|
+
if invar_hook.exists():
|
|
249
|
+
invar_hook.write_text(content)
|
|
250
|
+
invar_hook.chmod(0o755)
|
|
251
|
+
updated.append(hook_type)
|
|
252
|
+
|
|
253
|
+
if updated:
|
|
254
|
+
console.print(f"[green]✓[/green] Claude hooks synced ({len(updated)} files)")
|
|
255
|
+
if failed:
|
|
256
|
+
console.print(f"[yellow]⚠[/yellow] {len(failed)} hook(s) failed to sync")
|
|
257
|
+
|
|
258
|
+
return Success(updated)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# @shell_complexity: Hook removal with backup restoration
|
|
262
|
+
def remove_claude_hooks(
|
|
263
|
+
project_path: Path,
|
|
264
|
+
console: Console,
|
|
265
|
+
) -> Result[None, str]:
|
|
266
|
+
"""
|
|
267
|
+
Remove Invar Claude Code hooks.
|
|
268
|
+
|
|
269
|
+
Restores user hooks from backup if available.
|
|
270
|
+
"""
|
|
271
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
272
|
+
|
|
273
|
+
if not hooks_dir.exists():
|
|
274
|
+
console.print("[yellow]No hooks directory found[/yellow]")
|
|
275
|
+
return Success(None)
|
|
276
|
+
|
|
277
|
+
console.print("[bold]Removing Invar Claude Code hooks...[/bold]")
|
|
278
|
+
|
|
279
|
+
for hook_type in HOOK_TYPES:
|
|
280
|
+
invar_hook = hooks_dir / f"invar.{hook_type}.sh"
|
|
281
|
+
wrapper_hook = hooks_dir / f"{hook_type}.sh"
|
|
282
|
+
backup_hook = hooks_dir / f"{hook_type}.sh.user_backup"
|
|
283
|
+
|
|
284
|
+
# Remove Invar hook
|
|
285
|
+
if invar_hook.exists():
|
|
286
|
+
invar_hook.unlink()
|
|
287
|
+
console.print(f" [red]Removed[/red] invar.{hook_type}.sh")
|
|
288
|
+
|
|
289
|
+
# Restore user backup if exists
|
|
290
|
+
if backup_hook.exists():
|
|
291
|
+
if wrapper_hook.exists():
|
|
292
|
+
wrapper_hook.unlink()
|
|
293
|
+
backup_hook.rename(wrapper_hook)
|
|
294
|
+
console.print(f" [green]Restored[/green] {hook_type}.sh (from backup)")
|
|
295
|
+
elif wrapper_hook.exists():
|
|
296
|
+
# Check if it's just an Invar wrapper
|
|
297
|
+
content = wrapper_hook.read_text()
|
|
298
|
+
if f"invar.{hook_type}.sh" in content and "user_backup" not in content:
|
|
299
|
+
wrapper_hook.unlink()
|
|
300
|
+
console.print(f" [red]Removed[/red] {hook_type}.sh (Invar wrapper)")
|
|
301
|
+
|
|
302
|
+
console.print("[bold green]✓ Invar hooks removed[/bold green]")
|
|
303
|
+
return Success(None)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def disable_claude_hooks(
|
|
307
|
+
project_path: Path,
|
|
308
|
+
console: Console,
|
|
309
|
+
) -> Result[None, str]:
|
|
310
|
+
"""Temporarily disable Invar hooks."""
|
|
311
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
312
|
+
|
|
313
|
+
if not hooks_dir.exists():
|
|
314
|
+
return Failure("No hooks directory found")
|
|
315
|
+
|
|
316
|
+
disabled_marker = hooks_dir / DISABLED_MARKER
|
|
317
|
+
disabled_marker.touch()
|
|
318
|
+
|
|
319
|
+
console.print("[yellow]✓ Invar hooks disabled[/yellow]")
|
|
320
|
+
console.print(f"[dim]Remove {HOOKS_SUBDIR}/{DISABLED_MARKER} to re-enable[/dim]")
|
|
321
|
+
return Success(None)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def enable_claude_hooks(
|
|
325
|
+
project_path: Path,
|
|
326
|
+
console: Console,
|
|
327
|
+
) -> Result[None, str]:
|
|
328
|
+
"""Re-enable Invar hooks."""
|
|
329
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
330
|
+
disabled_marker = hooks_dir / DISABLED_MARKER
|
|
331
|
+
|
|
332
|
+
if disabled_marker.exists():
|
|
333
|
+
disabled_marker.unlink()
|
|
334
|
+
console.print("[green]✓ Invar hooks enabled[/green]")
|
|
335
|
+
else:
|
|
336
|
+
console.print("[dim]Invar hooks were not disabled[/dim]")
|
|
337
|
+
|
|
338
|
+
return Success(None)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# @shell_complexity: Status display with version extraction
|
|
342
|
+
def hooks_status(
|
|
343
|
+
project_path: Path,
|
|
344
|
+
console: Console,
|
|
345
|
+
) -> Result[dict[str, str], str]:
|
|
346
|
+
"""Check status of Claude Code hooks."""
|
|
347
|
+
hooks_dir = project_path / HOOKS_SUBDIR
|
|
348
|
+
|
|
349
|
+
status: dict[str, str] = {}
|
|
350
|
+
|
|
351
|
+
if not hooks_dir.exists():
|
|
352
|
+
console.print("[yellow]No hooks directory[/yellow]")
|
|
353
|
+
return Success({"status": "not_installed"})
|
|
354
|
+
|
|
355
|
+
disabled = (hooks_dir / DISABLED_MARKER).exists()
|
|
356
|
+
if disabled:
|
|
357
|
+
console.print("[yellow]⏸ Invar hooks disabled[/yellow]")
|
|
358
|
+
status["status"] = "disabled"
|
|
359
|
+
else:
|
|
360
|
+
status["status"] = "enabled"
|
|
361
|
+
|
|
362
|
+
for hook_type in HOOK_TYPES:
|
|
363
|
+
invar_hook = hooks_dir / f"invar.{hook_type}.sh"
|
|
364
|
+
if invar_hook.exists():
|
|
365
|
+
status[hook_type] = "installed"
|
|
366
|
+
# Try to get version
|
|
367
|
+
try:
|
|
368
|
+
content = invar_hook.read_text()
|
|
369
|
+
match = re.search(r"Protocol: v([\d.]+)", content)
|
|
370
|
+
if match:
|
|
371
|
+
status[f"{hook_type}_version"] = match.group(1)
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
else:
|
|
375
|
+
status[hook_type] = "not_installed"
|
|
376
|
+
|
|
377
|
+
# Display status
|
|
378
|
+
installed_count = sum(1 for h in HOOK_TYPES if status.get(h) == "installed")
|
|
379
|
+
if installed_count == len(HOOK_TYPES):
|
|
380
|
+
version = status.get("UserPromptSubmit_version", "?")
|
|
381
|
+
console.print(f"[green]✓ All hooks installed (v{version})[/green]")
|
|
382
|
+
elif installed_count > 0:
|
|
383
|
+
console.print(f"[yellow]⚠ Partial installation ({installed_count}/{len(HOOK_TYPES)})[/yellow]")
|
|
384
|
+
else:
|
|
385
|
+
console.print("[dim]No Invar hooks installed[/dim]")
|
|
386
|
+
|
|
387
|
+
return Success(status)
|
invar/shell/commands/guard.py
CHANGED
|
@@ -437,6 +437,7 @@ def rules(
|
|
|
437
437
|
|
|
438
438
|
|
|
439
439
|
# DX-48b: Import commands from shell/commands/
|
|
440
|
+
from invar.shell.commands.hooks import app as hooks_app # DX-57
|
|
440
441
|
from invar.shell.commands.init import init
|
|
441
442
|
from invar.shell.commands.mutate import mutate # DX-28
|
|
442
443
|
from invar.shell.commands.sync_self import sync_self # DX-49
|
|
@@ -448,6 +449,7 @@ app.command()(update)
|
|
|
448
449
|
app.command()(test)
|
|
449
450
|
app.command()(verify)
|
|
450
451
|
app.command()(mutate) # DX-28: Mutation testing
|
|
452
|
+
app.add_typer(hooks_app, name="hooks") # DX-57: Claude Code hooks management
|
|
451
453
|
|
|
452
454
|
# DX-56: Create dev subcommand group for developer commands
|
|
453
455
|
dev_app = typer.Typer(
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hooks management command for Invar.
|
|
3
|
+
|
|
4
|
+
DX-57: Claude Code hooks installation, removal, and management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from returns.result import Failure, Result
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from invar.shell.claude_hooks import (
|
|
16
|
+
disable_claude_hooks,
|
|
17
|
+
enable_claude_hooks,
|
|
18
|
+
hooks_status,
|
|
19
|
+
install_claude_hooks,
|
|
20
|
+
remove_claude_hooks,
|
|
21
|
+
sync_claude_hooks,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _handle_result(result: Result[object, str]) -> None:
|
|
28
|
+
"""Print error message if result is Failure."""
|
|
29
|
+
if isinstance(result, Failure):
|
|
30
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(help="Manage Claude Code hooks")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# @invar:allow entry_point_too_thick: Typer command with options and docstring
|
|
36
|
+
@app.callback(invoke_without_command=True)
|
|
37
|
+
def hooks(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
40
|
+
remove: bool = typer.Option(False, "--remove", help="Remove Invar hooks"),
|
|
41
|
+
disable: bool = typer.Option(False, "--disable", help="Temporarily disable hooks"),
|
|
42
|
+
enable: bool = typer.Option(False, "--enable", help="Re-enable disabled hooks"),
|
|
43
|
+
install: bool = typer.Option(False, "--install", help="Install hooks"),
|
|
44
|
+
sync: bool = typer.Option(False, "--sync", help="Sync hooks with current INVAR.md"),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Manage Claude Code hooks for Invar.
|
|
48
|
+
|
|
49
|
+
Without flags, shows current hooks status.
|
|
50
|
+
|
|
51
|
+
\b
|
|
52
|
+
Examples:
|
|
53
|
+
invar hooks # Show status
|
|
54
|
+
invar hooks --install # Install hooks
|
|
55
|
+
invar hooks --remove # Permanently remove hooks
|
|
56
|
+
invar hooks --disable # Temporarily disable
|
|
57
|
+
invar hooks --enable # Re-enable
|
|
58
|
+
invar hooks --sync # Update with current INVAR.md
|
|
59
|
+
"""
|
|
60
|
+
path = path.resolve()
|
|
61
|
+
|
|
62
|
+
if remove:
|
|
63
|
+
_handle_result(remove_claude_hooks(path, console))
|
|
64
|
+
elif disable:
|
|
65
|
+
_handle_result(disable_claude_hooks(path, console))
|
|
66
|
+
elif enable:
|
|
67
|
+
_handle_result(enable_claude_hooks(path, console))
|
|
68
|
+
elif install:
|
|
69
|
+
_handle_result(install_claude_hooks(path, console))
|
|
70
|
+
elif sync:
|
|
71
|
+
_handle_result(sync_claude_hooks(path, console))
|
|
72
|
+
else:
|
|
73
|
+
# Default: show status
|
|
74
|
+
_handle_result(hooks_status(path, console))
|
invar/shell/commands/init.py
CHANGED
|
@@ -5,6 +5,7 @@ Shell module: handles project initialization.
|
|
|
5
5
|
DX-21B: Added --claude flag for Claude Code integration.
|
|
6
6
|
DX-55: Unified idempotent init command with smart merge.
|
|
7
7
|
DX-56: Uses unified template sync engine for file generation.
|
|
8
|
+
DX-57: Added Claude Code hooks installation.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
@@ -19,6 +20,10 @@ from rich.console import Console
|
|
|
19
20
|
|
|
20
21
|
from invar.core.sync_helpers import SyncConfig
|
|
21
22
|
from invar.core.template_parser import ClaudeMdState
|
|
23
|
+
from invar.shell.claude_hooks import (
|
|
24
|
+
install_claude_hooks,
|
|
25
|
+
sync_claude_hooks,
|
|
26
|
+
)
|
|
22
27
|
from invar.shell.commands.merge import (
|
|
23
28
|
ProjectState,
|
|
24
29
|
detect_project_state,
|
|
@@ -201,6 +206,10 @@ def init(
|
|
|
201
206
|
hooks: bool = typer.Option(
|
|
202
207
|
True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
|
|
203
208
|
),
|
|
209
|
+
claude_hooks: bool = typer.Option(
|
|
210
|
+
None, "--claude-hooks/--no-claude-hooks",
|
|
211
|
+
help="Install Claude Code hooks (default: ON when --claude, DX-57)"
|
|
212
|
+
),
|
|
204
213
|
skills: bool = typer.Option(
|
|
205
214
|
True, "--skills/--no-skills", help="Create .claude/skills/ (default: ON, use --no-skills for Cursor)"
|
|
206
215
|
),
|
|
@@ -241,6 +250,7 @@ def init(
|
|
|
241
250
|
Use --mcp-method to specify MCP execution method (uvx, command, python).
|
|
242
251
|
Use --dirs to always create directories, --no-dirs to skip.
|
|
243
252
|
Use --no-hooks to skip pre-commit hooks installation.
|
|
253
|
+
Use --no-claude-hooks to skip Claude Code hooks (DX-57).
|
|
244
254
|
Use --no-skills to skip .claude/skills/ creation (for Cursor users).
|
|
245
255
|
Use --yes to accept defaults without prompting.
|
|
246
256
|
"""
|
|
@@ -406,6 +416,24 @@ def init(
|
|
|
406
416
|
if hooks:
|
|
407
417
|
install_hooks(path, console)
|
|
408
418
|
|
|
419
|
+
# DX-57: Handle Claude Code hooks
|
|
420
|
+
# Determine if we should install/update Claude hooks
|
|
421
|
+
should_install_claude_hooks = (
|
|
422
|
+
claude_hooks is True # Explicitly requested
|
|
423
|
+
or (claude_hooks is None and claude) # Default ON when --claude
|
|
424
|
+
)
|
|
425
|
+
should_skip_claude_hooks = claude_hooks is False
|
|
426
|
+
|
|
427
|
+
if should_install_claude_hooks and not should_skip_claude_hooks:
|
|
428
|
+
# Install Claude hooks
|
|
429
|
+
install_claude_hooks(path, console)
|
|
430
|
+
elif not should_skip_claude_hooks:
|
|
431
|
+
# Check if hooks already installed and need sync
|
|
432
|
+
claude_hooks_dir = path / ".claude" / "hooks"
|
|
433
|
+
if (claude_hooks_dir / "invar.UserPromptSubmit.sh").exists():
|
|
434
|
+
# Sync existing hooks (idempotent update)
|
|
435
|
+
sync_claude_hooks(path, console)
|
|
436
|
+
|
|
409
437
|
if not config_added and not (path / "INVAR.md").exists():
|
|
410
438
|
console.print("[yellow]Invar already configured.[/yellow]")
|
|
411
439
|
|
|
@@ -446,11 +474,13 @@ def _show_check_preview(state: ProjectState, path: Path, version: str) -> None:
|
|
|
446
474
|
console.print(" - CLAUDE.md")
|
|
447
475
|
console.print(" - .invar/context.md")
|
|
448
476
|
console.print(" - .claude/skills/")
|
|
477
|
+
console.print(" - .claude/hooks/ (DX-57, with --claude)")
|
|
449
478
|
console.print(" - .pre-commit-config.yaml")
|
|
450
479
|
case "update":
|
|
451
480
|
console.print("Would update:")
|
|
452
481
|
console.print(f" - CLAUDE.md (managed section v{state.version} → v{version})")
|
|
453
482
|
console.print(" - .claude/skills/* (refresh)")
|
|
483
|
+
console.print(" - .claude/hooks/* (refresh, if installed)")
|
|
454
484
|
case "recover":
|
|
455
485
|
console.print("[yellow]Would recover:[/yellow]")
|
|
456
486
|
console.print(" - CLAUDE.md (restore regions, preserve content)")
|
|
@@ -22,7 +22,6 @@ from invar.core.sync_helpers import (
|
|
|
22
22
|
should_skip_file,
|
|
23
23
|
)
|
|
24
24
|
from invar.core.template_parser import (
|
|
25
|
-
detect_claude_md_state,
|
|
26
25
|
format_preserved_content,
|
|
27
26
|
parse_invar_regions,
|
|
28
27
|
reconstruct_file,
|
|
@@ -282,20 +281,54 @@ def _merge_region_content(
|
|
|
282
281
|
config: SyncConfig,
|
|
283
282
|
) -> str:
|
|
284
283
|
"""Merge existing content with new template based on DX-55 state."""
|
|
285
|
-
state = detect_claude_md_state(existing_content)
|
|
286
284
|
updates: dict[str, str] = {}
|
|
287
285
|
|
|
288
|
-
|
|
289
|
-
|
|
286
|
+
# Reset mode: discard all user content, use fresh template
|
|
287
|
+
if config.reset:
|
|
288
|
+
# Only inject project additions for CLAUDE.md if available
|
|
289
|
+
if dest_rel == "CLAUDE.md" and project_additions:
|
|
290
|
+
parsed = parse_invar_regions(new_content)
|
|
291
|
+
if "project" in parsed.regions:
|
|
292
|
+
return reconstruct_file(parsed, {"project": project_additions})
|
|
293
|
+
return new_content
|
|
294
|
+
|
|
295
|
+
# Generic region detection: check if primary region markers exist
|
|
296
|
+
# This works for any region scheme (managed/user, skill/extensions, etc.)
|
|
297
|
+
primary_open = f"<!--invar:{primary_region}"
|
|
298
|
+
primary_close = f"<!--/invar:{primary_region}-->"
|
|
299
|
+
user_open = f"<!--invar:{user_region}-->"
|
|
300
|
+
user_close = f"<!--/invar:{user_region}-->"
|
|
301
|
+
|
|
302
|
+
has_primary_open = primary_open in existing_content
|
|
303
|
+
has_primary_close = primary_close in existing_content
|
|
304
|
+
has_user_open = user_open in existing_content
|
|
305
|
+
has_user_close = user_close in existing_content
|
|
306
|
+
|
|
307
|
+
primary_complete = has_primary_open and has_primary_close
|
|
308
|
+
user_complete = has_user_open and has_user_close
|
|
309
|
+
|
|
310
|
+
# Determine state based on generic region presence
|
|
311
|
+
if primary_complete and user_complete:
|
|
312
|
+
# Intact: update primary region, preserve user region
|
|
290
313
|
existing_parsed = parse_invar_regions(existing_content)
|
|
291
314
|
updates[primary_region] = new_parsed.regions[primary_region].content
|
|
315
|
+
|
|
316
|
+
# DX-58: Also update critical region if present (always overwrite from template)
|
|
317
|
+
if "critical" in new_parsed.regions and "critical" in existing_parsed.regions:
|
|
318
|
+
updates["critical"] = new_parsed.regions["critical"].content
|
|
319
|
+
|
|
292
320
|
if dest_rel == "CLAUDE.md" and project_additions and "project" in existing_parsed.regions:
|
|
293
321
|
updates["project"] = project_additions
|
|
294
322
|
return reconstruct_file(existing_parsed, updates)
|
|
295
323
|
|
|
296
|
-
elif
|
|
297
|
-
#
|
|
298
|
-
|
|
324
|
+
elif has_primary_open or has_user_open:
|
|
325
|
+
# Partial: some markers present but incomplete - salvage user content
|
|
326
|
+
existing_parsed = parse_invar_regions(existing_content)
|
|
327
|
+
user_content = ""
|
|
328
|
+
if user_region in existing_parsed.regions:
|
|
329
|
+
user_content = existing_parsed.regions[user_region].content
|
|
330
|
+
if not user_content:
|
|
331
|
+
user_content = strip_invar_markers(existing_content)
|
|
299
332
|
if user_content:
|
|
300
333
|
user_content = format_preserved_content(user_content, date.today().isoformat())
|
|
301
334
|
parsed = parse_invar_regions(new_content)
|
|
@@ -306,8 +339,8 @@ def _merge_region_content(
|
|
|
306
339
|
return reconstruct_file(parsed, updates)
|
|
307
340
|
return new_content
|
|
308
341
|
|
|
309
|
-
|
|
310
|
-
#
|
|
342
|
+
else:
|
|
343
|
+
# Missing: no Invar markers - preserve entire content as user content
|
|
311
344
|
preserved = format_preserved_content(existing_content, date.today().isoformat())
|
|
312
345
|
parsed = parse_invar_regions(new_content)
|
|
313
346
|
if user_region in parsed.regions:
|
|
@@ -317,8 +350,6 @@ def _merge_region_content(
|
|
|
317
350
|
return reconstruct_file(parsed, updates)
|
|
318
351
|
return new_content
|
|
319
352
|
|
|
320
|
-
return new_content
|
|
321
|
-
|
|
322
353
|
|
|
323
354
|
# @shell_complexity: File creation with multiple template types
|
|
324
355
|
def _sync_create_only(
|
invar/shell/commands/test.py
CHANGED
invar/shell/templates.py
CHANGED
|
@@ -434,7 +434,7 @@ Find your Python path: `python -c "import sys; print(sys.executable)"`
|
|
|
434
434
|
|
|
435
435
|
```bash
|
|
436
436
|
# Recommended: use uvx (no installation needed)
|
|
437
|
-
uvx invar-tools guard
|
|
437
|
+
uvx --from invar-tools invar guard
|
|
438
438
|
|
|
439
439
|
# Or install globally
|
|
440
440
|
pip install invar-tools
|
|
@@ -449,7 +449,7 @@ Run the MCP server directly:
|
|
|
449
449
|
|
|
450
450
|
```bash
|
|
451
451
|
# Using uvx
|
|
452
|
-
uvx invar-tools mcp
|
|
452
|
+
uvx --from invar-tools invar mcp
|
|
453
453
|
|
|
454
454
|
# Or if installed
|
|
455
455
|
invar mcp
|