invar-tools 1.5.0__py3-none-any.whl → 1.7.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/__init__.py +7 -1
- invar/core/entry_points.py +12 -10
- invar/core/formatter.py +15 -0
- invar/core/models.py +85 -0
- invar/shell/commands/guard.py +38 -9
- invar/shell/commands/init.py +8 -79
- invar/shell/commands/uninstall.py +341 -0
- invar/shell/config.py +46 -0
- invar/shell/guard_output.py +10 -0
- invar/shell/templates.py +1 -70
- invar/templates/CLAUDE.md.template +18 -10
- invar/templates/commands/audit.md +6 -0
- invar/templates/commands/guard.md +6 -0
- invar/templates/config/CLAUDE.md.jinja +51 -30
- invar/templates/config/context.md.jinja +14 -0
- invar/templates/pre-commit-config.yaml.template +2 -0
- invar/templates/protocol/INVAR.md +1 -0
- invar/templates/skills/develop/SKILL.md.jinja +2 -1
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/METADATA +1 -1
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/RECORD +25 -26
- invar/templates/aider.conf.yml.template +0 -31
- invar/templates/cursorrules.template +0 -40
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.5.0.dist-info → invar_tools-1.7.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DX-69: Uninstall Invar from a project.
|
|
3
|
+
|
|
4
|
+
Safely removes Invar files and configurations while preserving user content.
|
|
5
|
+
Uses marker-based detection to identify Invar-generated content.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def has_invar_marker(path: Path) -> bool:
|
|
22
|
+
"""Check if a file has Invar markers (_invar: or <!--invar:)."""
|
|
23
|
+
try:
|
|
24
|
+
content = path.read_text()
|
|
25
|
+
return "_invar:" in content or "<!--invar:" in content
|
|
26
|
+
except (OSError, UnicodeDecodeError):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_invar_region_marker(path: Path) -> bool:
|
|
31
|
+
"""Check if a file has # invar:begin marker."""
|
|
32
|
+
try:
|
|
33
|
+
content = path.read_text()
|
|
34
|
+
return "# invar:begin" in content
|
|
35
|
+
except (OSError, UnicodeDecodeError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def has_invar_hook_marker(path: Path) -> bool:
|
|
40
|
+
"""Check if a hook file has invar marker."""
|
|
41
|
+
try:
|
|
42
|
+
content = path.read_text()
|
|
43
|
+
# Invar hooks have specific patterns
|
|
44
|
+
return "invar" in content.lower() and (
|
|
45
|
+
"INVAR_" in content
|
|
46
|
+
or "invar guard" in content
|
|
47
|
+
or "invar_guard" in content
|
|
48
|
+
or "invar." in content.lower() # wrapper files: source invar.PreToolUse.sh
|
|
49
|
+
or "invar hook" in content.lower() # comment: # Invar hook wrapper
|
|
50
|
+
)
|
|
51
|
+
except (OSError, UnicodeDecodeError):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# @shell_orchestration: Regex patterns tightly coupled to file removal logic
|
|
56
|
+
def remove_invar_regions(content: str) -> str:
|
|
57
|
+
"""Remove <!--invar:xxx-->...<!--/invar:xxx--> regions except user region."""
|
|
58
|
+
patterns = [
|
|
59
|
+
# HTML-style regions (CLAUDE.md)
|
|
60
|
+
(r"<!--invar:critical-->.*?<!--/invar:critical-->\n?", ""),
|
|
61
|
+
(r"<!--invar:managed[^>]*-->.*?<!--/invar:managed-->\n?", ""),
|
|
62
|
+
(r"<!--invar:project-->.*?<!--/invar:project-->\n?", ""),
|
|
63
|
+
# Comment-style regions (.pre-commit-config.yaml)
|
|
64
|
+
(r"# invar:begin\n.*?# invar:end\n?", ""),
|
|
65
|
+
]
|
|
66
|
+
for pattern, replacement in patterns:
|
|
67
|
+
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
|
68
|
+
return content.strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def remove_mcp_invar_entry(path: Path) -> tuple[bool, str]:
|
|
72
|
+
"""Remove invar entry from .mcp.json, return (modified, new_content)."""
|
|
73
|
+
try:
|
|
74
|
+
content = path.read_text()
|
|
75
|
+
data = json.loads(content)
|
|
76
|
+
if "mcpServers" in data and "invar" in data["mcpServers"]:
|
|
77
|
+
del data["mcpServers"]["invar"]
|
|
78
|
+
# If no servers left, indicate file can be deleted
|
|
79
|
+
if not data["mcpServers"]:
|
|
80
|
+
return True, ""
|
|
81
|
+
return True, json.dumps(data, indent=2)
|
|
82
|
+
return False, content
|
|
83
|
+
except (OSError, json.JSONDecodeError):
|
|
84
|
+
return False, ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# @shell_complexity: Multi-file type detection requires comprehensive branching
|
|
88
|
+
def collect_removal_targets(path: Path) -> dict:
|
|
89
|
+
"""Collect files and directories to remove/modify."""
|
|
90
|
+
targets = {
|
|
91
|
+
"delete_dirs": [],
|
|
92
|
+
"delete_files": [],
|
|
93
|
+
"modify_files": [],
|
|
94
|
+
"skip": [],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Directories to delete entirely
|
|
98
|
+
invar_dir = path / ".invar"
|
|
99
|
+
if invar_dir.exists():
|
|
100
|
+
targets["delete_dirs"].append((".invar/", "directory"))
|
|
101
|
+
|
|
102
|
+
# Files to delete entirely
|
|
103
|
+
for file_name, description in [
|
|
104
|
+
("invar.toml", "config"),
|
|
105
|
+
("INVAR.md", "protocol"),
|
|
106
|
+
]:
|
|
107
|
+
file_path = path / file_name
|
|
108
|
+
if file_path.exists():
|
|
109
|
+
targets["delete_files"].append((file_name, description))
|
|
110
|
+
|
|
111
|
+
# Skills with _invar marker
|
|
112
|
+
skills_dir = path / ".claude" / "skills"
|
|
113
|
+
if skills_dir.exists():
|
|
114
|
+
for skill_dir in skills_dir.iterdir():
|
|
115
|
+
if skill_dir.is_dir():
|
|
116
|
+
skill_file = skill_dir / "SKILL.md"
|
|
117
|
+
if skill_file.exists():
|
|
118
|
+
if has_invar_marker(skill_file):
|
|
119
|
+
targets["delete_dirs"].append(
|
|
120
|
+
(f".claude/skills/{skill_dir.name}/", "skill, has _invar marker")
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
targets["skip"].append(
|
|
124
|
+
(f".claude/skills/{skill_dir.name}/", "no _invar marker")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Commands with _invar marker
|
|
128
|
+
commands_dir = path / ".claude" / "commands"
|
|
129
|
+
if commands_dir.exists():
|
|
130
|
+
for cmd_file in commands_dir.glob("*.md"):
|
|
131
|
+
if has_invar_marker(cmd_file):
|
|
132
|
+
targets["delete_files"].append(
|
|
133
|
+
(f".claude/commands/{cmd_file.name}", "command, has _invar marker")
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
targets["skip"].append(
|
|
137
|
+
(f".claude/commands/{cmd_file.name}", "no _invar marker")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Hooks with invar marker
|
|
141
|
+
hooks_dir = path / ".claude" / "hooks"
|
|
142
|
+
if hooks_dir.exists():
|
|
143
|
+
for hook_file in hooks_dir.glob("*.sh"):
|
|
144
|
+
if has_invar_hook_marker(hook_file):
|
|
145
|
+
targets["delete_files"].append(
|
|
146
|
+
(f".claude/hooks/{hook_file.name}", "hook, has invar marker")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# CLAUDE.md - modify, not delete
|
|
150
|
+
claude_md = path / "CLAUDE.md"
|
|
151
|
+
if claude_md.exists():
|
|
152
|
+
content = claude_md.read_text()
|
|
153
|
+
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
|
+
)
|
|
159
|
+
|
|
160
|
+
# .mcp.json - modify or delete
|
|
161
|
+
mcp_json = path / ".mcp.json"
|
|
162
|
+
if mcp_json.exists():
|
|
163
|
+
modified, new_content = remove_mcp_invar_entry(mcp_json)
|
|
164
|
+
if modified:
|
|
165
|
+
if new_content:
|
|
166
|
+
targets["modify_files"].append((".mcp.json", "remove mcpServers.invar"))
|
|
167
|
+
else:
|
|
168
|
+
targets["delete_files"].append((".mcp.json", "only had invar config"))
|
|
169
|
+
|
|
170
|
+
# Config files with region markers (DX-69: cursor/aider removed)
|
|
171
|
+
for file_name in [".pre-commit-config.yaml"]:
|
|
172
|
+
file_path = path / file_name
|
|
173
|
+
if file_path.exists():
|
|
174
|
+
if has_invar_region_marker(file_path):
|
|
175
|
+
content = file_path.read_text()
|
|
176
|
+
cleaned = remove_invar_regions(content)
|
|
177
|
+
if cleaned:
|
|
178
|
+
targets["modify_files"].append((file_name, "remove invar:begin..end block"))
|
|
179
|
+
else:
|
|
180
|
+
targets["delete_files"].append((file_name, "only had invar content"))
|
|
181
|
+
else:
|
|
182
|
+
targets["skip"].append((file_name, "no invar:begin marker"))
|
|
183
|
+
|
|
184
|
+
# Empty directories to clean up
|
|
185
|
+
for dir_name in ["src/core", "src/shell"]:
|
|
186
|
+
dir_path = path / dir_name
|
|
187
|
+
if dir_path.exists() and dir_path.is_dir():
|
|
188
|
+
if not any(dir_path.iterdir()):
|
|
189
|
+
targets["delete_dirs"].append((dir_name, "empty directory"))
|
|
190
|
+
|
|
191
|
+
return targets
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# @shell_complexity: Rich output formatting for different target categories
|
|
195
|
+
def show_preview(targets: dict) -> None:
|
|
196
|
+
"""Display what would be removed/modified."""
|
|
197
|
+
console.print("\n[bold]Invar Uninstall Preview[/bold]")
|
|
198
|
+
console.print("=" * 40)
|
|
199
|
+
|
|
200
|
+
if targets["delete_dirs"] or targets["delete_files"]:
|
|
201
|
+
console.print("\n[red]Will DELETE:[/red]")
|
|
202
|
+
for item, desc in targets["delete_dirs"]:
|
|
203
|
+
console.print(f" {item:40} ({desc})")
|
|
204
|
+
for item, desc in targets["delete_files"]:
|
|
205
|
+
console.print(f" {item:40} ({desc})")
|
|
206
|
+
|
|
207
|
+
if targets["modify_files"]:
|
|
208
|
+
console.print("\n[yellow]Will MODIFY:[/yellow]")
|
|
209
|
+
for item, desc in targets["modify_files"]:
|
|
210
|
+
console.print(f" {item:40} ({desc})")
|
|
211
|
+
|
|
212
|
+
if targets["skip"]:
|
|
213
|
+
console.print("\n[dim]Will SKIP:[/dim]")
|
|
214
|
+
for item, desc in targets["skip"]:
|
|
215
|
+
console.print(f" {item:40} ({desc})")
|
|
216
|
+
|
|
217
|
+
console.print()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# @shell_complexity: Different file types require different removal strategies
|
|
221
|
+
def execute_removal(path: Path, targets: dict) -> None:
|
|
222
|
+
"""Execute the removal/modification operations."""
|
|
223
|
+
# Delete directories
|
|
224
|
+
for dir_name, _ in targets["delete_dirs"]:
|
|
225
|
+
dir_path = path / dir_name.rstrip("/")
|
|
226
|
+
if dir_path.exists():
|
|
227
|
+
shutil.rmtree(dir_path)
|
|
228
|
+
console.print(f"[red]Deleted[/red] {dir_name}")
|
|
229
|
+
|
|
230
|
+
# Delete files
|
|
231
|
+
for file_name, _ in targets["delete_files"]:
|
|
232
|
+
file_path = path / file_name
|
|
233
|
+
if file_path.exists():
|
|
234
|
+
file_path.unlink()
|
|
235
|
+
console.print(f"[red]Deleted[/red] {file_name}")
|
|
236
|
+
|
|
237
|
+
# Modify files
|
|
238
|
+
for file_name, _desc in targets["modify_files"]:
|
|
239
|
+
file_path = path / file_name
|
|
240
|
+
if not file_path.exists():
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if file_name == ".mcp.json":
|
|
244
|
+
modified, new_content = remove_mcp_invar_entry(file_path)
|
|
245
|
+
if modified and new_content:
|
|
246
|
+
file_path.write_text(new_content)
|
|
247
|
+
console.print(f"[yellow]Modified[/yellow] {file_name}")
|
|
248
|
+
else:
|
|
249
|
+
content = file_path.read_text()
|
|
250
|
+
cleaned = remove_invar_regions(content)
|
|
251
|
+
if cleaned:
|
|
252
|
+
file_path.write_text(cleaned + "\n")
|
|
253
|
+
console.print(f"[yellow]Modified[/yellow] {file_name}")
|
|
254
|
+
else:
|
|
255
|
+
file_path.unlink()
|
|
256
|
+
console.print(f"[red]Deleted[/red] {file_name} (empty after cleanup)")
|
|
257
|
+
|
|
258
|
+
# Clean up empty .claude directory if it exists and is empty
|
|
259
|
+
claude_dir = path / ".claude"
|
|
260
|
+
if claude_dir.exists():
|
|
261
|
+
# Check subdirectories
|
|
262
|
+
for subdir in ["skills", "commands", "hooks"]:
|
|
263
|
+
subdir_path = claude_dir / subdir
|
|
264
|
+
if subdir_path.exists() and not any(subdir_path.iterdir()):
|
|
265
|
+
subdir_path.rmdir()
|
|
266
|
+
console.print(f"[dim]Removed empty[/dim] .claude/{subdir}/")
|
|
267
|
+
# Check if .claude itself is empty
|
|
268
|
+
if not any(claude_dir.iterdir()):
|
|
269
|
+
claude_dir.rmdir()
|
|
270
|
+
console.print("[dim]Removed empty[/dim] .claude/")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def uninstall(
|
|
274
|
+
path: Path = typer.Argument(
|
|
275
|
+
Path(),
|
|
276
|
+
help="Project path",
|
|
277
|
+
exists=True,
|
|
278
|
+
file_okay=False,
|
|
279
|
+
dir_okay=True,
|
|
280
|
+
resolve_path=True,
|
|
281
|
+
),
|
|
282
|
+
dry_run: bool = typer.Option(
|
|
283
|
+
False,
|
|
284
|
+
"--dry-run",
|
|
285
|
+
"-n",
|
|
286
|
+
help="Show what would be removed without removing",
|
|
287
|
+
),
|
|
288
|
+
force: bool = typer.Option(
|
|
289
|
+
False,
|
|
290
|
+
"--force",
|
|
291
|
+
"-f",
|
|
292
|
+
help="Skip confirmation prompt",
|
|
293
|
+
),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Remove Invar from a project.
|
|
296
|
+
|
|
297
|
+
Safely removes Invar-generated files and configurations while
|
|
298
|
+
preserving user content. Uses marker-based detection.
|
|
299
|
+
|
|
300
|
+
Examples:
|
|
301
|
+
invar uninstall --dry-run # Preview changes
|
|
302
|
+
invar uninstall # Remove with confirmation
|
|
303
|
+
invar uninstall --force # Remove without confirmation
|
|
304
|
+
"""
|
|
305
|
+
# Check if this is an Invar project
|
|
306
|
+
invar_toml = path / "invar.toml"
|
|
307
|
+
invar_md = path / "INVAR.md"
|
|
308
|
+
invar_dir = path / ".invar"
|
|
309
|
+
|
|
310
|
+
if not (invar_toml.exists() or invar_md.exists() or invar_dir.exists()):
|
|
311
|
+
console.print("[yellow]Warning:[/yellow] This doesn't appear to be an Invar project.")
|
|
312
|
+
console.print("No invar.toml, INVAR.md, or .invar/ directory found.")
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
|
|
315
|
+
# Collect targets
|
|
316
|
+
targets = collect_removal_targets(path)
|
|
317
|
+
|
|
318
|
+
# Check if there's anything to do
|
|
319
|
+
if not any([targets["delete_dirs"], targets["delete_files"], targets["modify_files"]]):
|
|
320
|
+
console.print("[green]Nothing to remove.[/green] Project is clean.")
|
|
321
|
+
raise typer.Exit(0)
|
|
322
|
+
|
|
323
|
+
# Show preview
|
|
324
|
+
show_preview(targets)
|
|
325
|
+
|
|
326
|
+
# Dry run exits here
|
|
327
|
+
if dry_run:
|
|
328
|
+
console.print("[dim]Dry run complete. No changes made.[/dim]")
|
|
329
|
+
raise typer.Exit(0)
|
|
330
|
+
|
|
331
|
+
# Confirmation
|
|
332
|
+
if not force:
|
|
333
|
+
confirm = typer.confirm("Proceed with uninstall?", default=False)
|
|
334
|
+
if not confirm:
|
|
335
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
336
|
+
raise typer.Exit(0)
|
|
337
|
+
|
|
338
|
+
# Execute
|
|
339
|
+
execute_removal(path, targets)
|
|
340
|
+
|
|
341
|
+
console.print("\n[green]✓ Invar has been removed from the project.[/green]")
|
invar/shell/config.py
CHANGED
|
@@ -267,6 +267,52 @@ def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigS
|
|
|
267
267
|
return Failure(f"Failed to find config: {e}")
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
# @shell_complexity: Project root discovery requires checking multiple markers
|
|
271
|
+
def find_project_root(start_path: "Path") -> "Path": # noqa: UP037
|
|
272
|
+
"""
|
|
273
|
+
Find project root by walking up from start_path looking for config files.
|
|
274
|
+
|
|
275
|
+
Looks for (in order): pyproject.toml, invar.toml, .invar/, .git/
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
start_path: Starting path (file or directory)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Project root directory (absolute path), or start_path's parent if no markers found
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
>>> from pathlib import Path
|
|
285
|
+
>>> import tempfile
|
|
286
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
287
|
+
... root = Path(tmpdir).resolve()
|
|
288
|
+
... (root / "pyproject.toml").touch()
|
|
289
|
+
... subdir = root / "src" / "core"
|
|
290
|
+
... subdir.mkdir(parents=True)
|
|
291
|
+
... found = find_project_root(subdir / "file.py")
|
|
292
|
+
... found == root
|
|
293
|
+
True
|
|
294
|
+
"""
|
|
295
|
+
from pathlib import Path
|
|
296
|
+
|
|
297
|
+
current = Path(start_path).resolve() # Resolve to absolute path
|
|
298
|
+
if current.is_file():
|
|
299
|
+
current = current.parent
|
|
300
|
+
|
|
301
|
+
# Walk up looking for project markers
|
|
302
|
+
for parent in [current, *current.parents]:
|
|
303
|
+
if (parent / "pyproject.toml").exists():
|
|
304
|
+
return parent
|
|
305
|
+
if (parent / "invar.toml").exists():
|
|
306
|
+
return parent
|
|
307
|
+
if (parent / ".invar").is_dir():
|
|
308
|
+
return parent
|
|
309
|
+
if (parent / ".git").exists():
|
|
310
|
+
return parent
|
|
311
|
+
|
|
312
|
+
# Fallback to the starting directory
|
|
313
|
+
return current
|
|
314
|
+
|
|
315
|
+
|
|
270
316
|
def _read_toml(path: Path) -> Result[dict[str, Any], str]:
|
|
271
317
|
"""Read and parse a TOML file."""
|
|
272
318
|
try:
|
invar/shell/guard_output.py
CHANGED
|
@@ -209,6 +209,16 @@ def output_rich(
|
|
|
209
209
|
if issue_parts:
|
|
210
210
|
console.print(f"[dim]Issues: {', '.join(issue_parts)}[/dim]")
|
|
211
211
|
|
|
212
|
+
# DX-66: Escape hatch summary (only show if any exist)
|
|
213
|
+
if report.escape_hatches.count > 0:
|
|
214
|
+
escape_count = report.escape_hatches.count
|
|
215
|
+
by_rule = report.escape_hatches.by_rule
|
|
216
|
+
rule_parts = [f"{count} {rule}" for rule, count in sorted(by_rule.items())]
|
|
217
|
+
console.print(
|
|
218
|
+
f"\n[bold]Escape hatches:[/bold] {escape_count} "
|
|
219
|
+
f"({', '.join(rule_parts)})"
|
|
220
|
+
)
|
|
221
|
+
|
|
212
222
|
# Code Health display (only when guard passes)
|
|
213
223
|
if report.passed and report.files_checked > 0:
|
|
214
224
|
# Calculate health: 100% for 0 warnings, decreases by 5% per warning, min 50%
|
invar/shell/templates.py
CHANGED
|
@@ -187,30 +187,16 @@ def copy_skills_directory(dest: Path, console) -> Result[bool, str]:
|
|
|
187
187
|
return Failure(f"Failed to copy skills: {e}")
|
|
188
188
|
|
|
189
189
|
|
|
190
|
-
# Agent configuration
|
|
190
|
+
# Agent configuration - Claude Code only (DX-69: simplified, cursor/aider removed)
|
|
191
191
|
AGENT_CONFIGS = {
|
|
192
192
|
"claude": {
|
|
193
193
|
"file": "CLAUDE.md",
|
|
194
194
|
"template": "CLAUDE.md.template",
|
|
195
|
-
"reference": '> **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.\n',
|
|
196
|
-
"check_pattern": "INVAR.md",
|
|
197
|
-
},
|
|
198
|
-
"cursor": {
|
|
199
|
-
"file": ".cursorrules",
|
|
200
|
-
"template": "cursorrules.template",
|
|
201
|
-
"reference": "Follow the Invar Protocol in INVAR.md.\n\n",
|
|
202
|
-
"check_pattern": "INVAR.md",
|
|
203
|
-
},
|
|
204
|
-
"aider": {
|
|
205
|
-
"file": ".aider.conf.yml",
|
|
206
|
-
"template": "aider.conf.yml.template",
|
|
207
|
-
"reference": "# Follow the Invar Protocol in INVAR.md\nread:\n - INVAR.md\n",
|
|
208
195
|
"check_pattern": "INVAR.md",
|
|
209
196
|
},
|
|
210
197
|
}
|
|
211
198
|
|
|
212
199
|
|
|
213
|
-
# @shell_complexity: Agent config detection across multiple locations
|
|
214
200
|
def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
|
|
215
201
|
"""
|
|
216
202
|
Detect existing agent configuration files.
|
|
@@ -244,61 +230,6 @@ def detect_agent_configs(path: Path) -> Result[dict[str, str], str]:
|
|
|
244
230
|
return Failure(f"Failed to detect agent configs: {e}")
|
|
245
231
|
|
|
246
232
|
|
|
247
|
-
# @shell_complexity: Reference addition with existing check
|
|
248
|
-
def add_invar_reference(path: Path, agent: str, console) -> Result[bool, str]:
|
|
249
|
-
"""Add Invar reference to an existing agent config file."""
|
|
250
|
-
if agent not in AGENT_CONFIGS:
|
|
251
|
-
return Failure(f"Unknown agent: {agent}")
|
|
252
|
-
|
|
253
|
-
config = AGENT_CONFIGS[agent]
|
|
254
|
-
config_path = path / config["file"]
|
|
255
|
-
|
|
256
|
-
if not config_path.exists():
|
|
257
|
-
return Failure(f"Config file not found: {config['file']}")
|
|
258
|
-
|
|
259
|
-
try:
|
|
260
|
-
content = config_path.read_text()
|
|
261
|
-
if config["check_pattern"] in content:
|
|
262
|
-
return Success(False) # Already configured
|
|
263
|
-
|
|
264
|
-
# Prepend reference
|
|
265
|
-
new_content = config["reference"] + content
|
|
266
|
-
config_path.write_text(new_content)
|
|
267
|
-
console.print(f"[green]Updated[/green] {config['file']} (added Invar reference)")
|
|
268
|
-
return Success(True)
|
|
269
|
-
except OSError as e:
|
|
270
|
-
return Failure(f"Failed to update {config['file']}: {e}")
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# @shell_complexity: Config creation with template selection
|
|
274
|
-
def create_agent_config(path: Path, agent: str, console) -> Result[bool, str]:
|
|
275
|
-
"""
|
|
276
|
-
Create agent config from template (DX-17).
|
|
277
|
-
|
|
278
|
-
Creates full template file for agents that don't have an existing config.
|
|
279
|
-
"""
|
|
280
|
-
if agent not in AGENT_CONFIGS:
|
|
281
|
-
return Failure(f"Unknown agent: {agent}")
|
|
282
|
-
|
|
283
|
-
config = AGENT_CONFIGS[agent]
|
|
284
|
-
config_path = path / config["file"]
|
|
285
|
-
|
|
286
|
-
if config_path.exists():
|
|
287
|
-
return Success(False) # Already exists
|
|
288
|
-
|
|
289
|
-
# Use template if available
|
|
290
|
-
template_name = config.get("template")
|
|
291
|
-
if template_name:
|
|
292
|
-
result = copy_template(template_name, path, config["file"])
|
|
293
|
-
if isinstance(result, Success) and result.unwrap():
|
|
294
|
-
console.print(f"[green]Created[/green] {config['file']} (Invar workflow enforcement)")
|
|
295
|
-
return Success(True)
|
|
296
|
-
elif isinstance(result, Failure):
|
|
297
|
-
return result
|
|
298
|
-
|
|
299
|
-
return Success(False)
|
|
300
|
-
|
|
301
|
-
|
|
302
233
|
# @shell_complexity: MCP server config with JSON manipulation
|
|
303
234
|
def configure_mcp_server(path: Path, console) -> Result[list[str], str]:
|
|
304
235
|
"""
|
|
@@ -120,18 +120,26 @@ Guard triggers `review_suggested` for: security-sensitive files, escape hatches
|
|
|
120
120
|
|
|
121
121
|
## Workflow Routing (MANDATORY)
|
|
122
122
|
|
|
123
|
-
When user message contains these triggers, you MUST invoke the
|
|
123
|
+
When user message contains these triggers, you MUST use the **Skill tool** to invoke the skill:
|
|
124
124
|
|
|
125
|
-
| Trigger Words | Skill | Notes |
|
|
126
|
-
|
|
127
|
-
| "review", "review and fix" |
|
|
128
|
-
| "implement", "add", "fix", "update" |
|
|
129
|
-
| "why", "explain", "investigate" |
|
|
130
|
-
| "compare", "should we", "design" |
|
|
125
|
+
| Trigger Words | Skill Tool Call | Notes |
|
|
126
|
+
|---------------|-----------------|-------|
|
|
127
|
+
| "review", "review and fix" | `Skill(skill="review")` | Adversarial review with fix loop |
|
|
128
|
+
| "implement", "add", "fix", "update" | `Skill(skill="develop")` | Unless in review context |
|
|
129
|
+
| "why", "explain", "investigate" | `Skill(skill="investigate")` | Research mode, no code changes |
|
|
130
|
+
| "compare", "should we", "design" | `Skill(skill="propose")` | Decision facilitation |
|
|
131
|
+
|
|
132
|
+
**⚠️ CRITICAL: You must call the Skill tool, not just follow the workflow mentally.**
|
|
133
|
+
|
|
134
|
+
The Skill tool reads `.claude/skills/<skill>/SKILL.md` which contains:
|
|
135
|
+
- Detailed phase instructions (USBV breakdown)
|
|
136
|
+
- Error handling rules
|
|
137
|
+
- Timeout policies
|
|
138
|
+
- Incremental development patterns (DX-63)
|
|
131
139
|
|
|
132
140
|
**Violation check (before writing ANY code):**
|
|
133
|
-
- "
|
|
134
|
-
- "
|
|
141
|
+
- "Did I call `Skill(skill="...")`?"
|
|
142
|
+
- "Am I following the SKILL.md instructions?"
|
|
135
143
|
|
|
136
144
|
<!--/invar:managed-->
|
|
137
145
|
|
|
@@ -146,7 +154,7 @@ When user message contains these triggers, you MUST invoke the corresponding ski
|
|
|
146
154
|
<!-- ========================================================================
|
|
147
155
|
USER REGION - EDITABLE
|
|
148
156
|
Add your team conventions and project-specific rules below.
|
|
149
|
-
This section is preserved across invar update and sync
|
|
157
|
+
This section is preserved across `invar update` and `invar dev sync`.
|
|
150
158
|
======================================================================== -->
|
|
151
159
|
<!--/invar:user-->
|
|
152
160
|
|