invar-tools 1.0.0__py3-none-any.whl → 1.3.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.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
@@ -27,7 +27,7 @@ from invar.core.rules import check_all_rules
27
27
  from invar.core.utils import get_exit_code
28
28
  from invar.shell.config import load_config
29
29
  from invar.shell.fs import scan_project
30
- from invar.shell.guard_output import output_agent, output_json, output_rich
30
+ from invar.shell.guard_output import output_agent, output_rich
31
31
 
32
32
  app = typer.Typer(
33
33
  name="invar",
@@ -37,6 +37,8 @@ app = typer.Typer(
37
37
  console = Console()
38
38
 
39
39
 
40
+ # @shell_orchestration: Statistics helper for CLI guard output
41
+ # @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
40
42
  def _count_core_functions(file_info) -> tuple[int, int]:
41
43
  """Count functions and functions with contracts in a Core file (P24)."""
42
44
  from invar.core.models import SymbolKind
@@ -54,11 +56,19 @@ def _count_core_functions(file_info) -> tuple[int, int]:
54
56
  return (total, with_contracts)
55
57
 
56
58
 
59
+ # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
60
+ # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
57
61
  def _scan_and_check(
58
62
  path: Path, config: RuleConfig, only_files: set[Path] | None = None
59
63
  ) -> Result[GuardReport, str]:
60
64
  """Scan project files and check against rules."""
65
+ from invar.core.entry_points import extract_escape_hatches
66
+ from invar.core.review_trigger import check_duplicate_escape_reasons
67
+ from invar.core.shell_architecture import check_complexity_debt
68
+
61
69
  report = GuardReport(files_checked=0)
70
+ all_escapes: list[tuple[str, str, str]] = [] # DX-33: (file, rule, reason)
71
+
62
72
  for file_result in scan_project(path, only_files):
63
73
  if isinstance(file_result, Failure):
64
74
  console.print(f"[yellow]Warning:[/yellow] {file_result.failure()}")
@@ -70,43 +80,64 @@ def _scan_and_check(
70
80
  report.update_coverage(total, with_contracts)
71
81
  for violation in check_all_rules(file_info, config):
72
82
  report.add_violation(violation)
83
+ # DX-33: Collect escape hatches for cross-file analysis
84
+ if file_info.source:
85
+ for rule, reason in extract_escape_hatches(file_info.source):
86
+ all_escapes.append((file_info.path, rule, reason))
87
+
88
+ # DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
89
+ for debt_violation in check_complexity_debt(
90
+ report.violations, config.shell_complexity_debt_limit
91
+ ):
92
+ report.add_violation(debt_violation)
93
+
94
+ # DX-33: Check for duplicate escape reasons across files
95
+ for escape_violation in check_duplicate_escape_reasons(all_escapes):
96
+ report.add_violation(escape_violation)
97
+
73
98
  return Success(report)
74
99
 
75
100
 
101
+ # @invar:allow entry_point_too_thick: Main CLI entry point, orchestrates all verification phases
76
102
  @app.command()
77
103
  def guard(
78
104
  path: Path = typer.Argument(
79
105
  Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
80
106
  ),
81
107
  strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
108
+ changed: bool = typer.Option(
109
+ False, "--changed", help="Only check git-modified files"
110
+ ),
111
+ static: bool = typer.Option(
112
+ False, "--static", help="Static analysis only, skip all runtime tests"
113
+ ),
114
+ human: bool = typer.Option(
115
+ False, "--human", help="Force human-readable output (for testing/debugging)"
116
+ ),
117
+ # DX-26: Deprecated flags kept for backward compatibility
82
118
  no_strict_pure: bool = typer.Option(
83
- False, "--no-strict-pure", help="Disable purity checks (internal imports, impure calls)"
119
+ False, "--no-strict-pure", hidden=True, help="[Deprecated] Disable purity checks"
84
120
  ),
85
121
  pedantic: bool = typer.Option(
86
- False, "--pedantic", help="Show all violations including off-by-default rules"
122
+ False, "--pedantic", hidden=True, help="[Deprecated] Show off-by-default rules"
87
123
  ),
88
124
  explain: bool = typer.Option(
89
- False, "--explain", help="Show detailed explanations and limitations"
90
- ),
91
- changed: bool = typer.Option(
92
- False, "--changed", help="Only check git-modified files"
125
+ False, "--explain", hidden=True, help="[Deprecated] Show detailed explanations"
93
126
  ),
94
127
  agent: bool = typer.Option(
95
- False, "--agent", help="Output JSON with fix instructions for agents"
128
+ False, "--agent", help="Force JSON output (for inspecting agent format)"
96
129
  ),
97
130
  json_output: bool = typer.Option(
98
- False, "--json", help="Output as JSON (simple format, no fix instructions)"
131
+ False, "--json", hidden=True, help="[Deprecated] Use TTY auto-detection instead"
99
132
  ),
100
- static: bool = typer.Option(
101
- False, "--static", help="Static analysis only, skip all runtime tests"
133
+ coverage: bool = typer.Option(
134
+ False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
102
135
  ),
103
136
  ) -> None:
104
137
  """Check project against Invar architecture rules.
105
138
 
106
139
  Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
107
140
  Use --static for quick static-only checks (~0.5s vs ~5s full).
108
-
109
- DX-19: Simplified to 2 levels (Zero decisions).
110
141
  """
111
142
  from invar.shell.guard_helpers import (
112
143
  collect_files_to_check,
@@ -150,58 +181,106 @@ def guard(
150
181
  raise typer.Exit(1)
151
182
  report = scan_result.unwrap()
152
183
 
153
- # Determine output mode
154
- use_agent_output, use_json_output = _determine_output_mode(
155
- json_output, agent
156
- )
184
+ # DX-26: Simplified output mode (TTY auto-detect + --human override)
185
+ use_agent_output = _determine_output_mode(human, agent, json_output)
157
186
 
158
187
  # DX-19: Simplified to 2 levels (STATIC or STANDARD)
159
188
  verification_level = VerificationLevel.STATIC if static else VerificationLevel.STANDARD
160
189
  level_name = "STATIC" if static else "STANDARD"
161
190
 
162
191
  # Show verification level (human mode)
163
- if not use_agent_output and not use_json_output:
192
+ if not use_agent_output:
164
193
  _show_verification_level(verification_level)
165
194
 
166
195
  # Run verification phases
167
196
  static_exit_code = get_exit_code(report, strict)
168
197
  doctest_passed, doctest_output = True, ""
169
- crosshair_passed, crosshair_output = True, {}
170
- property_passed, property_output = True, {}
198
+ crosshair_passed: bool = True
199
+ crosshair_output: dict = {}
200
+ property_passed: bool = True
201
+ property_output: dict = {}
202
+ # DX-37: Coverage data from doctest + hypothesis phases
203
+ doctest_coverage: dict | None = None
204
+ property_coverage: dict | None = None
205
+
206
+ # DX-37: Check coverage availability if requested
207
+ if coverage:
208
+ from invar.shell.coverage import check_coverage_available
209
+ cov_check = check_coverage_available()
210
+ if isinstance(cov_check, Failure):
211
+ console.print(f"[yellow]Warning:[/yellow] {cov_check.failure()}")
212
+ coverage = False # Disable coverage if not available
171
213
 
172
214
  # DX-19: STANDARD runs all verification phases
173
215
  if verification_level == VerificationLevel.STANDARD and static_exit_code == 0:
174
216
  checked_files = collect_files_to_check(path, checked_files)
175
217
 
176
- # Phase 1: Doctests
177
- doctest_passed, doctest_output = run_doctests_phase(checked_files, explain)
218
+ # Phase 1: Doctests (DX-37: with optional coverage)
219
+ doctest_passed, doctest_output, doctest_coverage = run_doctests_phase(
220
+ checked_files, explain, timeout=config.timeout_doctest,
221
+ collect_coverage=coverage,
222
+ )
178
223
 
179
224
  # Phase 2: CrossHair symbolic verification
225
+ # Note: CrossHair uses subprocess + symbolic execution, coverage not applicable
180
226
  crosshair_passed, crosshair_output = run_crosshair_phase(
181
227
  path, checked_files, doctest_passed, static_exit_code,
182
228
  changed_mode=changed,
229
+ timeout=config.timeout_crosshair,
230
+ per_condition_timeout=config.timeout_crosshair_per_condition,
183
231
  )
184
232
 
185
- # Phase 3: Hypothesis property tests
186
- property_passed, property_output = run_property_tests_phase(
187
- checked_files, doctest_passed, static_exit_code
233
+ # Phase 3: Hypothesis property tests (DX-37: with optional coverage)
234
+ property_passed, property_output, property_coverage = run_property_tests_phase(
235
+ checked_files, doctest_passed, static_exit_code,
236
+ collect_coverage=coverage,
188
237
  )
189
-
190
- # Output results
238
+ elif verification_level == VerificationLevel.STATIC:
239
+ # Static-only mode: explicitly mark verification as skipped
240
+ crosshair_output = {"status": "skipped", "reason": "static mode"}
241
+ property_output = {"status": "skipped", "reason": "static mode"}
242
+ elif static_exit_code != 0:
243
+ # Static failures: explicitly mark verification as skipped
244
+ crosshair_output = {"status": "skipped", "reason": "prior failures"}
245
+ property_output = {"status": "skipped", "reason": "prior failures"}
246
+
247
+ # DX-37: Merge coverage data from doctest + hypothesis
248
+ coverage_output: dict | None = None
249
+ if coverage and (doctest_coverage or property_coverage):
250
+ coverage_output = {
251
+ "enabled": True,
252
+ "phases_tracked": [],
253
+ "phases_excluded": ["crosshair"], # CrossHair uses symbolic execution
254
+ }
255
+ if doctest_coverage and doctest_coverage.get("collected"):
256
+ coverage_output["phases_tracked"].append("doctest")
257
+ if property_coverage and property_coverage.get("collected"):
258
+ coverage_output["phases_tracked"].append("hypothesis")
259
+ if "overall_branch_coverage" in property_coverage:
260
+ coverage_output["overall_branch_coverage"] = property_coverage["overall_branch_coverage"]
261
+
262
+ # DX-26: Unified output (agent JSON or human Rich)
191
263
  if use_agent_output:
192
264
  output_agent(
193
- report, doctest_passed, doctest_output, crosshair_output, level_name,
265
+ report, strict, doctest_passed, doctest_output, crosshair_output, level_name,
194
266
  property_output=property_output,
267
+ coverage_data=coverage_output, # DX-37
195
268
  )
196
- elif use_json_output:
197
- output_json(report)
198
269
  else:
199
- output_rich(report, config.strict_pure, changed, pedantic, explain)
270
+ output_rich(report, config.strict_pure, changed, pedantic, explain, static)
200
271
  output_verification_status(
201
272
  verification_level, static_exit_code, doctest_passed,
202
273
  doctest_output, crosshair_output, explain,
203
274
  property_output=property_output,
275
+ strict=strict,
204
276
  )
277
+ # DX-37: Show coverage info in human output
278
+ if coverage_output and coverage_output.get("phases_tracked"):
279
+ phases = coverage_output.get("phases_tracked", [])
280
+ overall = coverage_output.get("overall_branch_coverage", 0.0)
281
+ console.print(f"\n[bold]Coverage Analysis[/bold] ({' + '.join(phases)})")
282
+ console.print(f" Overall branch coverage: {overall}%")
283
+ console.print(" [dim]Note: CrossHair uses symbolic execution; coverage not applicable.[/dim]")
205
284
 
206
285
  # Exit with combined status
207
286
  all_passed = doctest_passed and crosshair_passed and property_passed
@@ -209,13 +288,26 @@ def guard(
209
288
  raise typer.Exit(final_exit)
210
289
 
211
290
 
212
- def _determine_output_mode(json_output: bool, agent: bool) -> tuple[bool, bool]:
213
- """Determine output mode based on flags and context."""
214
- if json_output:
215
- return False, True
216
- if agent or _detect_agent_mode():
217
- return True, False
218
- return False, False
291
+ # @shell_orchestration: Output mode decision helper for CLI
292
+ def _determine_output_mode(human: bool, agent: bool = False, json_output: bool = False) -> bool:
293
+ """Determine if agent JSON output should be used (DX-26).
294
+
295
+ DX-26: TTY auto-detection with --human override.
296
+ - --human flag → human output (for testing/debugging)
297
+ - TTY (terminal) → human output
298
+ - Non-TTY (pipe/redirect) → agent JSON output
299
+ - Deprecated --agent/--json flags → still work for backward compat
300
+ """
301
+ # --human flag always forces human output
302
+ if human:
303
+ return False # use_agent = False
304
+
305
+ # Deprecated flags (backward compat)
306
+ if json_output or agent:
307
+ return True # use_agent = True
308
+
309
+ # TTY auto-detection
310
+ return _detect_agent_mode() # Returns True if non-TTY
219
311
 
220
312
 
221
313
  def _show_verification_level(verification_level) -> None:
@@ -245,7 +337,7 @@ def map_command(
245
337
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
246
338
  ) -> None:
247
339
  """Generate symbol map with reference counts."""
248
- from invar.shell.perception import run_map
340
+ from invar.shell.commands.perception import run_map
249
341
 
250
342
  # Phase 9 P11: Auto-detect agent mode
251
343
  use_json = json_output or _detect_agent_mode()
@@ -261,7 +353,7 @@ def sig_command(
261
353
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
262
354
  ) -> None:
263
355
  """Extract signatures from a file or symbol."""
264
- from invar.shell.perception import run_sig
356
+ from invar.shell.commands.perception import run_sig
265
357
 
266
358
  # Phase 9 P11: Auto-detect agent mode
267
359
  use_json = json_output or _detect_agent_mode()
@@ -271,6 +363,7 @@ def sig_command(
271
363
  raise typer.Exit(1)
272
364
 
273
365
 
366
+ # @invar:allow entry_point_too_thick: Rules display with filtering and dual output modes
274
367
  @app.command()
275
368
  def rules(
276
369
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
@@ -343,15 +436,30 @@ def rules(
343
436
  console.print(f"\n[dim]{len(rules_list)} rules total. Use --json for full details.[/dim]")
344
437
 
345
438
 
346
- # Import commands from separate modules to reduce file size
347
- from invar.shell.init_cmd import init
348
- from invar.shell.test_cmd import test, verify
349
- from invar.shell.update_cmd import update
439
+ # DX-48b: Import commands from shell/commands/
440
+ from invar.shell.commands.init import init
441
+ from invar.shell.commands.mutate import mutate # DX-28
442
+ from invar.shell.commands.sync_self import sync_self # DX-49
443
+ from invar.shell.commands.test import test, verify
444
+ from invar.shell.commands.update import update
350
445
 
351
446
  app.command()(init)
352
447
  app.command()(update)
353
448
  app.command()(test)
354
449
  app.command()(verify)
450
+ app.command()(mutate) # DX-28: Mutation testing
451
+
452
+ # DX-56: Create dev subcommand group for developer commands
453
+ dev_app = typer.Typer(
454
+ name="dev",
455
+ help="Developer commands for Invar project development",
456
+ add_completion=False,
457
+ )
458
+ dev_app.command("sync")(sync_self) # DX-56: renamed from sync-self
459
+ app.add_typer(dev_app)
460
+
461
+ # DX-56: Keep sync-self as alias for backward compatibility (deprecated)
462
+ app.command("sync-self", hidden=True)(sync_self)
355
463
 
356
464
 
357
465
  if __name__ == "__main__":
@@ -3,6 +3,8 @@ Init command for Invar.
3
3
 
4
4
  Shell module: handles project initialization.
5
5
  DX-21B: Added --claude flag for Claude Code integration.
6
+ DX-55: Unified idempotent init command with smart merge.
7
+ DX-56: Uses unified template sync engine for file generation.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -15,17 +17,23 @@ import typer
15
17
  from returns.result import Failure, Success
16
18
  from rich.console import Console
17
19
 
20
+ from invar.core.sync_helpers import SyncConfig
21
+ from invar.core.template_parser import ClaudeMdState
22
+ from invar.shell.commands.merge import (
23
+ ProjectState,
24
+ detect_project_state,
25
+ )
26
+ from invar.shell.commands.template_sync import sync_templates
18
27
  from invar.shell.mcp_config import (
19
28
  detect_available_methods,
20
29
  generate_mcp_json,
21
30
  get_method_by_name,
22
31
  get_recommended_method,
23
32
  )
33
+ from invar.shell.template_engine import generate_from_manifest
24
34
  from invar.shell.templates import (
25
35
  add_config,
26
36
  add_invar_reference,
27
- copy_examples_directory,
28
- copy_template,
29
37
  create_agent_config,
30
38
  create_directories,
31
39
  detect_agent_configs,
@@ -35,6 +43,7 @@ from invar.shell.templates import (
35
43
  console = Console()
36
44
 
37
45
 
46
+ # @shell_complexity: Claude init with config file detection
38
47
  def run_claude_init(path: Path) -> bool:
39
48
  """
40
49
  Run 'claude /init' to generate intelligent CLAUDE.md.
@@ -95,16 +104,27 @@ def append_invar_reference_to_claude_md(path: Path) -> bool:
95
104
 
96
105
  ## Invar Protocol
97
106
 
98
- > **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.
107
+ > **Protocol:** Follow [INVAR.md](./INVAR.md) includes Check-In, USBV workflow, and Task Completion.
108
+
109
+ ### Check-In
110
+
111
+ Your first message MUST display:
112
+
113
+ ```
114
+ ✓ Check-In: [project] | [branch] | [clean/dirty]
115
+ ```
116
+
117
+ Read `.invar/context.md` first. Do NOT run guard/map at Check-In.
99
118
 
100
- Your **first message** for any implementation task MUST include actual output from:
119
+ ### Final
101
120
 
102
- ```bash
103
- invar guard --changed # or: invar_guard(changed=true)
104
- invar map --top 10 # or: invar_map(top=10)
121
+ Your last message MUST display:
122
+
123
+ ```
124
+ ✓ Final: guard PASS | 0 errors, 2 warnings
105
125
  ```
106
126
 
107
- **Use MCP tools if available**, otherwise use CLI commands.
127
+ Execute `invar guard` and show this one-line summary.
108
128
  """
109
129
 
110
130
  claude_md.write_text(content + invar_reference)
@@ -112,6 +132,7 @@ invar map --top 10 # or: invar_map(top=10)
112
132
  return True
113
133
 
114
134
 
135
+ # @shell_complexity: MCP config with method selection and validation
115
136
  def configure_mcp_with_method(
116
137
  path: Path, mcp_method: str | None
117
138
  ) -> None:
@@ -163,6 +184,7 @@ def show_available_mcp_methods() -> None:
163
184
  console.print(f" {marker} {method.method.value}: {method.description}")
164
185
 
165
186
 
187
+ # @shell_complexity: Project init with config detection and template setup
166
188
  def init(
167
189
  path: Path = typer.Argument(Path(), help="Project root directory"),
168
190
  claude: bool = typer.Option(
@@ -179,24 +201,107 @@ def init(
179
201
  hooks: bool = typer.Option(
180
202
  True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
181
203
  ),
204
+ skills: bool = typer.Option(
205
+ True, "--skills/--no-skills", help="Create .claude/skills/ (default: ON, use --no-skills for Cursor)"
206
+ ),
182
207
  yes: bool = typer.Option(
183
208
  False, "--yes", "-y", help="Accept defaults without prompting"
184
209
  ),
210
+ check: bool = typer.Option(
211
+ False, "--check", help="Preview changes without applying (DX-55)"
212
+ ),
213
+ force: bool = typer.Option(
214
+ False, "--force", help="Update even if already current (DX-55)"
215
+ ),
216
+ reset: bool = typer.Option(
217
+ False, "--reset", help="Dangerous: discard all user content (DX-55)"
218
+ ),
185
219
  ) -> None:
186
220
  """
187
- Initialize Invar configuration in a project.
221
+ Initialize or update Invar configuration (idempotent).
222
+
223
+ DX-55: This command is idempotent - safe to run multiple times.
224
+ It detects current state and does the right thing:
225
+
226
+ \b
227
+ - New project: Full setup
228
+ - Existing project: Update managed regions, preserve user content
229
+ - Corrupted/overwritten: Smart recovery with content preservation
188
230
 
189
231
  Works with or without pyproject.toml:
190
- - If pyproject.toml exists: adds [tool.invar.guard] section
232
+
233
+ \b
234
+ - If pyproject.toml exists: adds tool.invar section
191
235
  - Otherwise: creates invar.toml
192
236
 
193
- Use --claude to run 'claude /init' first (recommended for Claude Code users).
237
+ Use --check to preview changes without applying.
238
+ Use --force to update even if already current.
239
+ Use --reset to discard all user content (dangerous).
240
+ Use --claude to run 'claude /init' first.
194
241
  Use --mcp-method to specify MCP execution method (uvx, command, python).
195
242
  Use --dirs to always create directories, --no-dirs to skip.
196
243
  Use --no-hooks to skip pre-commit hooks installation.
244
+ Use --no-skills to skip .claude/skills/ creation (for Cursor users).
197
245
  Use --yes to accept defaults without prompting.
198
246
  """
199
- # DX-21B: Run claude /init if requested
247
+ from invar import __version__
248
+
249
+ # DX-55: Detect project state first
250
+ state = detect_project_state(path)
251
+
252
+ # --check mode: preview only
253
+ if check:
254
+ _show_check_preview(state, path, __version__)
255
+ return
256
+
257
+ # --reset mode: dangerous full reset
258
+ if reset:
259
+ if not yes and not typer.confirm(
260
+ "[red]This will DELETE all user customizations. Continue?[/red]",
261
+ default=False,
262
+ ):
263
+ console.print("[yellow]Cancelled[/yellow]")
264
+ return
265
+ # Fall through to full init with reset flag
266
+ state = ProjectState(
267
+ initialized=False,
268
+ claude_md_state=ClaudeMdState(state="absent"),
269
+ version="",
270
+ needs_update=True,
271
+ )
272
+
273
+ # DX-55: Handle based on detected state
274
+ action = state.action if not force else "update"
275
+
276
+ if action == "none" and not force:
277
+ # DX-55: Check for missing required files before declaring "no changes needed"
278
+ missing_files = []
279
+ if skills:
280
+ skill_files = [
281
+ ".claude/skills/develop/SKILL.md",
282
+ ".claude/skills/investigate/SKILL.md",
283
+ ".claude/skills/propose/SKILL.md",
284
+ ".claude/skills/review/SKILL.md",
285
+ ]
286
+ for skill_file in skill_files:
287
+ if not (path / skill_file).exists():
288
+ missing_files.append(skill_file)
289
+
290
+ if not missing_files:
291
+ console.print(f"[green]✓[/green] Invar v{__version__} configured (no changes needed)")
292
+ console.print("[dim]Use --force to refresh managed regions[/dim]")
293
+ return
294
+ else:
295
+ # Recreate missing files
296
+ console.print(f"[yellow]Detected:[/yellow] {len(missing_files)} missing file(s)")
297
+ result = generate_from_manifest(path, syntax="cli", files_to_generate=missing_files)
298
+ if isinstance(result, Success):
299
+ for generated_file in result.unwrap():
300
+ console.print(f"[green]Restored[/green] {generated_file}")
301
+ console.print(f"[green]✓[/green] Invar v{__version__} configured")
302
+ return
303
+
304
+ # DX-21B: Run claude /init if requested (before sync)
200
305
  if claude:
201
306
  claude_success = run_claude_init(path)
202
307
  if claude_success:
@@ -209,26 +314,49 @@ def init(
209
314
  raise typer.Exit(1)
210
315
  config_added = config_result.unwrap()
211
316
 
212
- # Create INVAR.md (protocol)
213
- result = copy_template("INVAR.md", path)
214
- if isinstance(result, Success) and result.unwrap():
215
- console.print("[green]Created[/green] INVAR.md (Invar Protocol)")
216
-
217
- # Copy examples directory
218
- copy_examples_directory(path, console)
219
-
220
- # Create .invar directory structure
317
+ # DX-56: Use unified sync engine for file generation
318
+ console.print("\n[bold]Creating Invar files...[/bold]")
319
+
320
+ # Check for project-additions.md
321
+ has_project_additions = (path / ".invar" / "project-additions.md").exists()
322
+
323
+ # Build skip patterns for --no-skills
324
+ skip_patterns: list[str] = []
325
+ if not skills:
326
+ skip_patterns.append(".claude/skills/*")
327
+
328
+ sync_config = SyncConfig(
329
+ syntax="cli",
330
+ inject_project_additions=has_project_additions,
331
+ force=force,
332
+ check=False, # Already handled above
333
+ reset=reset,
334
+ skip_patterns=skip_patterns,
335
+ )
336
+
337
+ # DX-56: Run unified sync engine (handles DX-55 state detection internally)
338
+ result = sync_templates(path, sync_config)
339
+ if isinstance(result, Failure):
340
+ console.print(f"[yellow]Warning:[/yellow] {result.failure()}")
341
+ else:
342
+ report = result.unwrap()
343
+ for file in report.created:
344
+ console.print(f"[green]Created[/green] {file}")
345
+ for file in report.updated:
346
+ console.print(f"[cyan]Updated[/cyan] {file}")
347
+ for error in report.errors:
348
+ console.print(f"[yellow]Warning:[/yellow] {error}")
349
+
350
+ # Create .invar directory structure (for proposals template - not in manifest)
221
351
  invar_dir = path / ".invar"
222
352
  if not invar_dir.exists():
223
353
  invar_dir.mkdir()
224
- result = copy_template("context.md.template", invar_dir, "context.md")
225
- if isinstance(result, Success) and result.unwrap():
226
- console.print("[green]Created[/green] .invar/context.md (context management)")
227
354
 
228
355
  # Create proposals directory for protocol governance
229
356
  proposals_dir = invar_dir / "proposals"
230
357
  if not proposals_dir.exists():
231
358
  proposals_dir.mkdir()
359
+ from invar.shell.templates import copy_template
232
360
  result = copy_template("proposal.md.template", proposals_dir, "TEMPLATE.md")
233
361
  if isinstance(result, Success) and result.unwrap():
234
362
  console.print("[green]Created[/green] .invar/proposals/TEMPLATE.md")
@@ -281,9 +409,53 @@ def init(
281
409
  if not config_added and not (path / "INVAR.md").exists():
282
410
  console.print("[yellow]Invar already configured.[/yellow]")
283
411
 
284
- # Summary
285
- console.print("\n[bold green]Invar initialized successfully![/bold green]")
412
+ # DX-55: Summary based on action taken
413
+ if action == "full_init":
414
+ console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
415
+ console.print("[dim]Note: If you run 'claude /init' later, just run 'invar init' again.[/dim]")
416
+ elif action == "recover":
417
+ console.print(f"\n[bold green]✓ Recovered Invar v{__version__}[/bold green]")
418
+ console.print("[dim]Review the merged content in CLAUDE.md[/dim]")
419
+ elif action == "update" or force:
420
+ console.print(f"\n[bold green]✓ Updated Invar v{__version__}[/bold green]")
421
+ console.print("[dim]Refreshed managed regions, preserved user content[/dim]")
422
+ else:
423
+ console.print("\n[bold green]Invar initialized successfully![/bold green]")
424
+
286
425
  if claude:
287
426
  console.print("[dim]Next: Review CLAUDE.md and start coding with Claude Code[/dim]")
288
- else:
289
- console.print("[dim]Tip: Use --claude for Claude Code integration[/dim]")
427
+
428
+
429
+ # @shell_complexity: Preview display requires multiple state-specific branches
430
+ def _show_check_preview(state: ProjectState, path: Path, version: str) -> None:
431
+ """Show preview of what would change (--check mode)."""
432
+ console.print(f"\n[bold]Invar v{version} - Preview Mode[/bold]\n")
433
+
434
+ console.print(f"Project state: [cyan]{state.claude_md_state.state}[/cyan]")
435
+ console.print(f"Initialized: [cyan]{state.initialized}[/cyan]")
436
+ console.print(f"Current version: [cyan]{state.version or 'N/A'}[/cyan]")
437
+ console.print(f"Needs update: [cyan]{state.needs_update}[/cyan]")
438
+ console.print(f"Action: [cyan]{state.action}[/cyan]\n")
439
+
440
+ match state.action:
441
+ case "none":
442
+ console.print("[green]No changes needed[/green]")
443
+ case "full_init":
444
+ console.print("Would create:")
445
+ console.print(" - INVAR.md")
446
+ console.print(" - CLAUDE.md")
447
+ console.print(" - .invar/context.md")
448
+ console.print(" - .claude/skills/")
449
+ console.print(" - .pre-commit-config.yaml")
450
+ case "update":
451
+ console.print("Would update:")
452
+ console.print(f" - CLAUDE.md (managed section v{state.version} → v{version})")
453
+ console.print(" - .claude/skills/* (refresh)")
454
+ case "recover":
455
+ console.print("[yellow]Would recover:[/yellow]")
456
+ console.print(" - CLAUDE.md (restore regions, preserve content)")
457
+ case "create":
458
+ console.print("Would create:")
459
+ console.print(" - CLAUDE.md")
460
+
461
+ console.print("\n[dim]Run 'invar init' to apply.[/dim]")