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.
@@ -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:
@@ -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 for multi-agent support (DX-11, DX-17)
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 corresponding skill:
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" | `/review` | Adversarial review with fix loop |
128
- | "implement", "add", "fix", "update" | `/develop` | Unless in review context |
129
- | "why", "explain", "investigate" | `/investigate` | Research mode, no code changes |
130
- | "compare", "should we", "design" | `/propose` | Decision facilitation |
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
- - "Am I in a workflow?"
134
- - "Did I invoke the correct skill?"
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-self.
157
+ This section is preserved across `invar update` and `invar dev sync`.
150
158
  ======================================================================== -->
151
159
  <!--/invar:user-->
152
160
 
@@ -1,3 +1,9 @@
1
+ ---
2
+ _invar:
3
+ version: "5.0"
4
+ type: command
5
+ ---
6
+
1
7
  # Audit
2
8
 
3
9
  Read-only code review. Reports issues without fixing them.
@@ -1,3 +1,9 @@
1
+ ---
2
+ _invar:
3
+ version: "5.0"
4
+ type: command
5
+ ---
6
+
1
7
  # Guard
2
8
 
3
9
  Run Invar verification on the project and report results.