invar-tools 1.8.0__py3-none-any.whl → 1.10.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 (110) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/language.py +88 -0
  3. invar/core/models.py +106 -0
  4. invar/core/patterns/detector.py +6 -1
  5. invar/core/patterns/p0_exhaustive.py +15 -3
  6. invar/core/patterns/p0_literal.py +15 -3
  7. invar/core/patterns/p0_newtype.py +15 -3
  8. invar/core/patterns/p0_nonempty.py +15 -3
  9. invar/core/patterns/p0_validation.py +15 -3
  10. invar/core/patterns/registry.py +5 -1
  11. invar/core/patterns/types.py +5 -1
  12. invar/core/property_gen.py +4 -0
  13. invar/core/rules.py +84 -18
  14. invar/core/sync_helpers.py +27 -1
  15. invar/core/ts_parsers.py +286 -0
  16. invar/core/ts_sig_parser.py +307 -0
  17. invar/node_tools/MANIFEST +7 -0
  18. invar/node_tools/__init__.py +51 -0
  19. invar/node_tools/fc-runner/cli.js +77 -0
  20. invar/node_tools/quick-check/cli.js +28 -0
  21. invar/node_tools/ts-analyzer/cli.js +480 -0
  22. invar/shell/claude_hooks.py +35 -12
  23. invar/shell/commands/guard.py +36 -1
  24. invar/shell/commands/init.py +82 -3
  25. invar/shell/commands/perception.py +157 -33
  26. invar/shell/commands/skill.py +187 -0
  27. invar/shell/commands/template_sync.py +65 -13
  28. invar/shell/commands/uninstall.py +60 -12
  29. invar/shell/commands/update.py +6 -14
  30. invar/shell/contract_coverage.py +1 -0
  31. invar/shell/fs.py +66 -13
  32. invar/shell/pi_hooks.py +6 -0
  33. invar/shell/prove/guard_ts.py +899 -0
  34. invar/shell/skill_manager.py +353 -0
  35. invar/shell/template_engine.py +28 -4
  36. invar/shell/templates.py +4 -4
  37. invar/templates/claude-md/python/critical-rules.md +33 -0
  38. invar/templates/claude-md/python/quick-reference.md +24 -0
  39. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  40. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  41. invar/templates/claude-md/universal/check-in.md +25 -0
  42. invar/templates/claude-md/universal/skills.md +73 -0
  43. invar/templates/claude-md/universal/workflow.md +55 -0
  44. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  45. invar/templates/config/AGENT.md.jinja +58 -0
  46. invar/templates/config/CLAUDE.md.jinja +16 -209
  47. invar/templates/config/context.md.jinja +19 -0
  48. invar/templates/examples/{README.md → python/README.md} +2 -0
  49. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  50. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  51. invar/templates/examples/python/core_shell.py +227 -0
  52. invar/templates/examples/python/functional.py +613 -0
  53. invar/templates/examples/typescript/README.md +31 -0
  54. invar/templates/examples/typescript/contracts.ts +163 -0
  55. invar/templates/examples/typescript/core_shell.ts +374 -0
  56. invar/templates/examples/typescript/functional.ts +601 -0
  57. invar/templates/examples/typescript/workflow.md +95 -0
  58. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  59. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  60. invar/templates/hooks/Stop.sh.jinja +1 -1
  61. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  62. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  63. invar/templates/manifest.toml +7 -6
  64. invar/templates/onboard/assessment.md.jinja +214 -0
  65. invar/templates/onboard/patterns/python.md +347 -0
  66. invar/templates/onboard/patterns/typescript.md +452 -0
  67. invar/templates/onboard/roadmap.md.jinja +168 -0
  68. invar/templates/protocol/INVAR.md.jinja +51 -0
  69. invar/templates/protocol/python/architecture-examples.md +41 -0
  70. invar/templates/protocol/python/contracts-syntax.md +56 -0
  71. invar/templates/protocol/python/markers.md +44 -0
  72. invar/templates/protocol/python/tools.md +24 -0
  73. invar/templates/protocol/python/troubleshooting.md +38 -0
  74. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  75. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  76. invar/templates/protocol/typescript/markers.md +48 -0
  77. invar/templates/protocol/typescript/tools.md +65 -0
  78. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  79. invar/templates/protocol/universal/architecture.md +36 -0
  80. invar/templates/protocol/universal/completion.md +14 -0
  81. invar/templates/protocol/universal/contracts-concept.md +37 -0
  82. invar/templates/protocol/universal/header.md +17 -0
  83. invar/templates/protocol/universal/session.md +17 -0
  84. invar/templates/protocol/universal/six-laws.md +10 -0
  85. invar/templates/protocol/universal/usbv.md +14 -0
  86. invar/templates/protocol/universal/visible-workflow.md +25 -0
  87. invar/templates/skills/develop/SKILL.md.jinja +39 -3
  88. invar/templates/skills/extensions/_registry.yaml +93 -0
  89. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  90. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  91. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  92. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  93. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  94. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  95. invar/templates/skills/extensions/security/SKILL.md +382 -0
  96. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  97. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  98. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  99. invar/templates/skills/review/SKILL.md.jinja +331 -71
  100. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/METADATA +304 -12
  101. invar_tools-1.10.0.dist-info/RECORD +173 -0
  102. invar/templates/examples/core_shell.py +0 -127
  103. invar/templates/protocol/INVAR.md +0 -310
  104. invar_tools-1.8.0.dist-info/RECORD +0 -116
  105. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  106. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
  108. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
  109. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
  110. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,187 @@
1
+ """
2
+ Skill management command for Invar.
3
+
4
+ LX-07: Extension Skills - CLI interface for skill 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
+ from rich.table import Table
15
+
16
+ from invar.shell.skill_manager import (
17
+ add_skill,
18
+ list_skills,
19
+ remove_skill,
20
+ update_skill,
21
+ )
22
+
23
+ PROJECT_SKILLS_DIR = ".claude/skills"
24
+
25
+ console = Console()
26
+
27
+ app = typer.Typer(help="Manage extension skills")
28
+
29
+
30
+ def _handle_result(result: Result[object, str]) -> None:
31
+ """Print error message if result is Failure."""
32
+ if isinstance(result, Failure):
33
+ console.print(f"[red]Error:[/red] {result.failure()}")
34
+ raise typer.Exit(1)
35
+
36
+
37
+ # @invar:allow entry_point_too_thick: Typer callback with docstring examples
38
+ @app.callback(invoke_without_command=True)
39
+ def skill_callback(ctx: typer.Context) -> None:
40
+ """
41
+ Manage Invar extension skills.
42
+
43
+ Extension skills add specialized capabilities like acceptance testing
44
+ and security auditing to your project.
45
+
46
+ \b
47
+ Examples:
48
+ invar skill # List all skills
49
+ invar skill add security # Install security skill
50
+ invar skill remove security
51
+ invar skill update security
52
+ """
53
+ # If no subcommand, show list
54
+ if ctx.invoked_subcommand is None:
55
+ list_cmd(Path())
56
+
57
+
58
+ # @invar:allow entry_point_too_thick: Rich table formatting for CLI output
59
+ @app.command("list")
60
+ def list_cmd(
61
+ path: Path = typer.Argument(Path(), help="Project root directory"),
62
+ ) -> None:
63
+ """List available extension skills."""
64
+ path = path.resolve()
65
+
66
+ result = list_skills(path, console)
67
+ if isinstance(result, Failure):
68
+ _handle_result(result)
69
+ return
70
+
71
+ skills = result.unwrap()
72
+
73
+ # Create rich table
74
+ table = Table(title="Extension Skills", show_header=True)
75
+ table.add_column("Name", style="cyan")
76
+ table.add_column("Tier", style="dim")
77
+ table.add_column("Status", style="green")
78
+ table.add_column("Description")
79
+
80
+ # Status styling
81
+ status_styles = {
82
+ "installed": "[green]installed[/green]",
83
+ "available": "[blue]available[/blue]",
84
+ "pending_discussion": "[yellow]pending[/yellow]",
85
+ }
86
+
87
+ for skill in skills:
88
+ status_display = status_styles.get(skill.status, skill.status)
89
+ isolation = " [dim](isolated)[/dim]" if skill.isolation else ""
90
+
91
+ table.add_row(
92
+ f"/{skill.name}",
93
+ skill.tier,
94
+ status_display,
95
+ f"{skill.description}{isolation}",
96
+ )
97
+
98
+ console.print(table)
99
+ console.print()
100
+ console.print("[dim]Use 'invar skill add <name>' to install a skill[/dim]")
101
+
102
+
103
+ # @invar:allow entry_point_too_thick: CLI output with usage hints
104
+ @app.command("add")
105
+ def add_cmd(
106
+ name: str = typer.Argument(..., help="Skill name to add"),
107
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
108
+ ) -> None:
109
+ """Add or update an extension skill (idempotent)."""
110
+ path = path.resolve()
111
+
112
+ # DX-71: add_skill now prints its own status (Adding/Updating)
113
+ result = add_skill(name, path, console)
114
+
115
+ if isinstance(result, Failure):
116
+ _handle_result(result)
117
+ return
118
+
119
+ console.print(f"[green]{result.unwrap()}[/green]")
120
+ console.print()
121
+ console.print(f"[dim]Use '/{name}' in Claude Code to invoke the skill[/dim]")
122
+
123
+
124
+ # @invar:allow entry_point_too_thick: CLI with confirmation dialog
125
+ @app.command("remove")
126
+ def remove_cmd(
127
+ name: str = typer.Argument(..., help="Skill name to remove"),
128
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
129
+ force: bool = typer.Option(False, "--force", "-f", help="Force removal"),
130
+ ) -> None:
131
+ """Remove an extension skill from the project."""
132
+ from invar.shell.skill_manager import has_user_extensions
133
+
134
+ path = path.resolve()
135
+ skill_dir = path / PROJECT_SKILLS_DIR / name
136
+
137
+ # DX-71 review: Check existence before any user interaction
138
+ if not skill_dir.exists():
139
+ console.print(f"[red]Error:[/red] Skill not installed: {name}")
140
+ raise typer.Exit(1)
141
+
142
+ # DX-71: Check extensions FIRST to avoid confusing confirmation→failure flow
143
+ if not force:
144
+ # If skill has user extensions, require --force (no confirmation dialog)
145
+ if has_user_extensions(skill_dir):
146
+ console.print(
147
+ f"[yellow]Warning:[/yellow] Skill '{name}' has custom extensions "
148
+ "content that will be lost."
149
+ )
150
+ console.print(
151
+ "[dim]Use --force to confirm removal, or backup extensions first.[/dim]"
152
+ )
153
+ raise typer.Exit(1)
154
+
155
+ # No extensions - show simple confirmation dialog
156
+ confirm = typer.confirm(f"Remove skill '{name}'?")
157
+ if not confirm:
158
+ console.print("[yellow]Cancelled[/yellow]")
159
+ raise typer.Exit(0)
160
+
161
+ console.print(f"[bold]Removing skill:[/bold] {name}")
162
+ # force=True here because we've already done CLI-level checks
163
+ result = remove_skill(name, path, console, force=True)
164
+
165
+ if isinstance(result, Failure):
166
+ _handle_result(result)
167
+ return
168
+
169
+ console.print(f"[green]{result.unwrap()}[/green]")
170
+
171
+
172
+ @app.command("update")
173
+ def update_cmd(
174
+ name: str = typer.Argument(..., help="Skill name to update"),
175
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
176
+ ) -> None:
177
+ """Update an extension skill (deprecated, use 'add' instead)."""
178
+ path = path.resolve()
179
+
180
+ # DX-71: update_skill now shows deprecation notice and delegates to add_skill
181
+ result = update_skill(name, path, console)
182
+
183
+ if isinstance(result, Failure):
184
+ _handle_result(result)
185
+ return
186
+
187
+ console.print(f"[green]{result.unwrap()}[/green]")
@@ -70,8 +70,12 @@ def sync_templates(path: Path, config: SyncConfig) -> Result[SyncReport, str]:
70
70
  manifest = manifest_result.unwrap()
71
71
  report = SyncReport()
72
72
 
73
- # Build variables for template rendering
74
- variables = {**manifest.get("variables", {}), "syntax": config.syntax}
73
+ # Build variables for template rendering (LX-05: include language)
74
+ variables = {
75
+ **manifest.get("variables", {}),
76
+ "syntax": config.syntax,
77
+ "language": config.language,
78
+ }
75
79
 
76
80
  # Load project additions if enabled
77
81
  project_additions = _load_project_additions(path) if config.inject_project_additions else ""
@@ -83,7 +87,12 @@ def sync_templates(path: Path, config: SyncConfig) -> Result[SyncReport, str]:
83
87
  for dest_rel, src_rel in fully_managed:
84
88
  if should_skip_file(dest_rel, config.skip_patterns):
85
89
  continue
86
- result = _sync_fully_managed(path, templates_dir, dest_rel, src_rel, config, report)
90
+ # Get template type from manifest (LX-05: support jinja for fully_managed)
91
+ template_config = manifest.get("templates", {}).get(dest_rel, {})
92
+ template_type = template_config.get("type", "copy")
93
+ result = _sync_fully_managed(
94
+ path, templates_dir, dest_rel, src_rel, template_type, variables, config, report
95
+ )
87
96
  if isinstance(result, Failure):
88
97
  report.errors.append(result.failure())
89
98
 
@@ -124,29 +133,44 @@ def _load_project_additions(path: Path) -> str:
124
133
  return ""
125
134
 
126
135
 
127
- # @shell_complexity: File I/O with multiple existence/content checks
136
+ # @shell_complexity: File I/O with multiple existence/content checks and Jinja rendering
128
137
  def _sync_fully_managed(
129
138
  path: Path,
130
139
  templates_dir: Path,
131
140
  dest_rel: str,
132
141
  src_rel: str,
142
+ template_type: str,
143
+ variables: dict,
133
144
  config: SyncConfig,
134
145
  report: SyncReport,
135
146
  ) -> Result[str, str]:
136
- """Sync a fully managed file (direct overwrite)."""
147
+ """Sync a fully managed file (direct overwrite).
148
+
149
+ LX-05: Now supports Jinja templates for composition.
150
+ """
137
151
  dest_file = path / dest_rel
138
152
  src_file = templates_dir / src_rel
139
153
 
140
154
  if not src_file.exists():
141
155
  return Failure(f"Template not found: {src_rel}")
142
156
 
143
- try:
144
- new_content = src_file.read_text()
145
- except OSError as e:
146
- return Failure(f"Failed to read template {src_rel}: {e}")
157
+ # LX-05: Render Jinja templates, copy plain files
158
+ if template_type == "jinja":
159
+ render_result = render_template_file(src_file, variables)
160
+ if isinstance(render_result, Failure):
161
+ return render_result
162
+ new_content = render_result.unwrap()
163
+ else:
164
+ try:
165
+ new_content = src_file.read_text()
166
+ except OSError as e:
167
+ return Failure(f"Failed to read template {src_rel}: {e}")
168
+
169
+ # Track if file exists BEFORE write (for correct created/updated reporting)
170
+ file_existed = dest_file.exists()
147
171
 
148
172
  # Check if update needed
149
- if dest_file.exists() and not config.force:
173
+ if file_existed and not config.force:
150
174
  try:
151
175
  if dest_file.read_text() == new_content:
152
176
  report.skipped.append(dest_rel)
@@ -162,7 +186,11 @@ def _sync_fully_managed(
162
186
  except OSError as e:
163
187
  return Failure(f"Failed to write {dest_rel}: {e}")
164
188
 
165
- report.updated.append(dest_rel) if dest_file.exists() else report.created.append(dest_rel)
189
+ # Report based on pre-write existence
190
+ if file_existed:
191
+ report.updated.append(dest_rel)
192
+ else:
193
+ report.created.append(dest_rel)
166
194
  return Success("synced")
167
195
 
168
196
 
@@ -341,6 +369,14 @@ def _merge_region_content(
341
369
 
342
370
  else:
343
371
  # Missing: no Invar markers - preserve entire content as user content
372
+ # Handle empty content - just return fresh template
373
+ if not existing_content.strip():
374
+ if dest_rel == "CLAUDE.md" and project_additions:
375
+ parsed = parse_invar_regions(new_content)
376
+ if "project" in parsed.regions:
377
+ return reconstruct_file(parsed, {"project": project_additions})
378
+ return new_content
379
+
344
380
  preserved = format_preserved_content(existing_content, date.today().isoformat())
345
381
  parsed = parse_invar_regions(new_content)
346
382
  if user_region in parsed.regions:
@@ -371,7 +407,8 @@ def _sync_create_only(
371
407
  report.skipped.append(dest_rel)
372
408
  return Success("skipped")
373
409
 
374
- if not src_file.exists():
410
+ # LX-05: Skip existence check for copy_dir_lang (has {language} placeholder)
411
+ if template_type != "copy_dir_lang" and not src_file.exists():
375
412
  return Failure(f"Template not found: {src_rel}")
376
413
 
377
414
  try:
@@ -386,9 +423,24 @@ def _sync_create_only(
386
423
  dest_file.write_text(result.unwrap())
387
424
  elif template_type == "copy_dir":
388
425
  if src_file.is_dir():
389
- shutil.copytree(src_file, dest_file)
426
+ # Ignore Python bytecode and cache directories
427
+ ignore = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
428
+ shutil.copytree(src_file, dest_file, ignore=ignore)
390
429
  else:
391
430
  return Failure(f"Expected directory: {src_rel}")
431
+ elif template_type == "copy_dir_lang":
432
+ # LX-05 hotfix: Language-aware directory copy
433
+ lang = variables.get("language", "python")
434
+ lang_src_rel = src_rel.replace("{language}", lang)
435
+ lang_src_file = templates_dir / lang_src_rel
436
+ if not lang_src_file.exists():
437
+ return Failure(f"Language-specific template not found: {lang_src_rel}")
438
+ if lang_src_file.is_dir():
439
+ # Ignore Python bytecode and cache directories
440
+ ignore = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
441
+ shutil.copytree(lang_src_file, dest_file, ignore=ignore)
442
+ else:
443
+ return Failure(f"Expected directory: {lang_src_rel}")
392
444
 
393
445
  report.created.append(dest_rel)
394
446
  return Success("created")
@@ -16,6 +16,7 @@ import typer
16
16
  from rich.console import Console
17
17
 
18
18
  from invar.shell.claude_hooks import is_invar_hook
19
+ from invar.shell.skill_manager import CORE_SKILLS, has_user_extensions
19
20
 
20
21
  console = Console()
21
22
 
@@ -199,13 +200,19 @@ def remove_hooks_from_settings(path: Path) -> tuple[bool, str]:
199
200
 
200
201
 
201
202
  # @shell_complexity: Multi-file type detection requires comprehensive branching
202
- def collect_removal_targets(path: Path) -> dict:
203
- """Collect files and directories to remove/modify."""
203
+ def collect_removal_targets(path: Path, remove_extensions: bool = False) -> dict:
204
+ """Collect files and directories to remove/modify.
205
+
206
+ Args:
207
+ path: Project root path
208
+ remove_extensions: If True, also remove extension skills
209
+ """
204
210
  targets = {
205
211
  "delete_dirs": [],
206
212
  "delete_files": [],
207
213
  "modify_files": [],
208
214
  "skip": [],
215
+ "extensions_preserved": [], # Extension skills that will be kept
209
216
  }
210
217
 
211
218
  # Directories to delete entirely
@@ -222,21 +229,45 @@ def collect_removal_targets(path: Path) -> dict:
222
229
  if file_path.exists():
223
230
  targets["delete_files"].append((file_name, description))
224
231
 
225
- # Skills with _invar marker
232
+ # Skills - distinguish core vs extension
226
233
  skills_dir = path / ".claude" / "skills"
227
234
  if skills_dir.exists():
228
235
  for skill_dir in skills_dir.iterdir():
229
236
  if skill_dir.is_dir():
237
+ skill_name = skill_dir.name
230
238
  skill_file = skill_dir / "SKILL.md"
231
- if skill_file.exists():
232
- if has_invar_marker(skill_file):
239
+
240
+ if not skill_file.exists():
241
+ continue
242
+
243
+ # Core skills are always removed
244
+ if skill_name in CORE_SKILLS:
245
+ targets["delete_dirs"].append(
246
+ (f".claude/skills/{skill_name}/", "core skill")
247
+ )
248
+ elif has_invar_marker(skill_file):
249
+ # Extension skill with Invar marker
250
+ if remove_extensions:
251
+ has_custom = has_user_extensions(skill_dir)
252
+ desc = "extension skill"
253
+ if has_custom:
254
+ desc += " (has custom content)"
233
255
  targets["delete_dirs"].append(
234
- (f".claude/skills/{skill_dir.name}/", "skill, has _invar marker")
256
+ (f".claude/skills/{skill_name}/", desc)
235
257
  )
236
258
  else:
237
- targets["skip"].append(
238
- (f".claude/skills/{skill_dir.name}/", "no _invar marker")
259
+ # Preserve extension skill
260
+ has_custom = has_user_extensions(skill_dir)
261
+ desc = "extension skill"
262
+ if has_custom:
263
+ desc += ", has custom content"
264
+ targets["extensions_preserved"].append(
265
+ (f".claude/skills/{skill_name}/", desc)
239
266
  )
267
+ else:
268
+ targets["skip"].append(
269
+ (f".claude/skills/{skill_name}/", "no _invar marker")
270
+ )
240
271
 
241
272
  # Commands with _invar marker
242
273
  commands_dir = path / ".claude" / "commands"
@@ -358,6 +389,14 @@ def show_preview(targets: dict) -> None:
358
389
  for item, desc in targets["modify_files"]:
359
390
  console.print(f" {item:40} ({desc})")
360
391
 
392
+ if targets.get("extensions_preserved"):
393
+ console.print("\n[cyan]Will PRESERVE (extension skills):[/cyan]")
394
+ for item, desc in targets["extensions_preserved"]:
395
+ console.print(f" {item:40} ({desc})")
396
+ console.print(
397
+ "\n[dim]Use --remove-extensions to also remove extension skills[/dim]"
398
+ )
399
+
361
400
  if targets["skip"]:
362
401
  console.print("\n[dim]Will SKIP:[/dim]")
363
402
  for item, desc in targets["skip"]:
@@ -446,16 +485,25 @@ def uninstall(
446
485
  "-f",
447
486
  help="Skip confirmation prompt",
448
487
  ),
488
+ remove_extensions: bool = typer.Option(
489
+ False,
490
+ "--remove-extensions",
491
+ help="Also remove extension skills (security, acceptance, etc.)",
492
+ ),
449
493
  ) -> None:
450
494
  """Remove Invar from a project.
451
495
 
452
496
  Safely removes Invar-generated files and configurations while
453
497
  preserving user content. Uses marker-based detection.
454
498
 
499
+ By default, extension skills are preserved. Use --remove-extensions
500
+ to also remove them.
501
+
455
502
  Examples:
456
- invar uninstall --dry-run # Preview changes
457
- invar uninstall # Remove with confirmation
458
- invar uninstall --force # Remove without confirmation
503
+ invar uninstall --dry-run # Preview changes
504
+ invar uninstall # Remove with confirmation
505
+ invar uninstall --force # Remove without confirmation
506
+ invar uninstall --remove-extensions # Also remove extension skills
459
507
  """
460
508
  # Check if this is an Invar project
461
509
  invar_toml = path / "invar.toml"
@@ -468,7 +516,7 @@ def uninstall(
468
516
  raise typer.Exit(1)
469
517
 
470
518
  # Collect targets
471
- targets = collect_removal_targets(path)
519
+ targets = collect_removal_targets(path, remove_extensions=remove_extensions)
472
520
 
473
521
  # Check if there's anything to do
474
522
  if not any([targets["delete_dirs"], targets["delete_files"], targets["modify_files"]]):
@@ -19,9 +19,7 @@ console = Console()
19
19
 
20
20
  def update(
21
21
  path: Path = typer.Argument(Path(), help="Project root directory"),
22
- check: bool = typer.Option(False, "--check", help="Preview changes"),
23
- force: bool = typer.Option(False, "--force", "-f", help="Update even if current"),
24
- yes: bool = typer.Option(False, "--yes", "-y", help="Accept defaults without prompting"),
22
+ preview: bool = typer.Option(False, "--preview", "--check", help="Preview changes (dry run)"),
25
23
  ) -> None:
26
24
  """
27
25
  Alias for 'invar init' (DX-55).
@@ -29,20 +27,14 @@ def update(
29
27
  Maintained for backwards compatibility.
30
28
  Both commands are now idempotent and do the same thing.
31
29
 
32
- Use 'invar init --check' to preview changes.
33
- Use 'invar init --force' to refresh even if current.
30
+ Use 'invar init --preview' to preview changes.
34
31
  """
35
32
  console.print("[dim]Note: 'update' is now an alias for 'init'[/dim]")
36
- # Pass all init parameters with explicit defaults to avoid typer.Option object issues
33
+ # Call init with matching parameters (DX-70 signature)
37
34
  return init_command(
38
35
  path=path,
39
36
  claude=False,
40
- mcp_method=None,
41
- dirs=None,
42
- hooks=True,
43
- skills=True,
44
- yes=yes,
45
- check=check,
46
- force=force,
47
- reset=False,
37
+ pi=False,
38
+ language=None,
39
+ preview=preview,
48
40
  )
@@ -266,6 +266,7 @@ def detect_batch_creation(
266
266
  return Success(None)
267
267
 
268
268
 
269
+ # @shell_orchestration: Report formatting for CLI output display
269
270
  # @shell_complexity: Report formatting with multiple conditional sections
270
271
  def format_contract_coverage_report(report: ContractCoverageReport) -> str:
271
272
  """Format coverage report for human-readable output."""
invar/shell/fs.py CHANGED
@@ -19,6 +19,16 @@ if TYPE_CHECKING:
19
19
  from pathlib import Path
20
20
 
21
21
 
22
+ # @shell_orchestration: Helper for file discovery, co-located with I/O functions
23
+ def _is_excluded(relative_str: str, exclude_patterns: list[str]) -> bool:
24
+ """Check if a relative path should be excluded."""
25
+ for pattern in exclude_patterns:
26
+ # Match whole path component, not prefix
27
+ if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
28
+ return True
29
+ return False
30
+
31
+
22
32
  # @shell_complexity: Recursive file discovery with gitignore and exclusions
23
33
  def discover_python_files(
24
34
  project_root: Path,
@@ -39,18 +49,51 @@ def discover_python_files(
39
49
  exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
40
50
 
41
51
  for py_file in project_root.rglob("*.py"):
42
- # Check exclusions
43
- relative = py_file.relative_to(project_root)
44
- relative_str = str(relative)
52
+ # Check exclusions using shared helper
53
+ relative_str = str(py_file.relative_to(project_root))
54
+ if not _is_excluded(relative_str, exclude_patterns):
55
+ yield py_file
45
56
 
46
- excluded = False
47
- for pattern in exclude_patterns:
48
- if relative_str.startswith(pattern) or f"/{pattern}/" in f"/{relative_str}":
49
- excluded = True
50
- break
51
57
 
52
- if not excluded:
53
- yield py_file
58
+ # @shell_complexity: Recursive TypeScript file discovery with exclusions
59
+ def discover_typescript_files(
60
+ project_root: Path,
61
+ exclude_patterns: list[str] | None = None,
62
+ ) -> Iterator[Path]:
63
+ """
64
+ Discover all TypeScript files in a project (LX-06).
65
+
66
+ Args:
67
+ project_root: Root directory to search
68
+ exclude_patterns: Patterns to exclude (uses config defaults if None)
69
+
70
+ Yields:
71
+ Path objects for each TypeScript file found
72
+ """
73
+ if exclude_patterns is None:
74
+ exclude_result = get_exclude_paths(project_root)
75
+ exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
76
+
77
+ # Always exclude node_modules and common build directories
78
+ default_ts_excludes = ["node_modules", "dist", "build", ".next", "out"]
79
+ all_excludes = list(set(list(exclude_patterns) + default_ts_excludes))
80
+
81
+ for ext in ("*.ts", "*.tsx"):
82
+ for ts_file in project_root.rglob(ext):
83
+ # Check exclusions
84
+ relative = ts_file.relative_to(project_root)
85
+ relative_str = str(relative)
86
+
87
+ excluded = False
88
+ for pattern in all_excludes:
89
+ # Match whole path component, not prefix
90
+ # e.g., "dist" should exclude "dist/file.ts" but NOT "dist_backup/file.ts"
91
+ if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
92
+ excluded = True
93
+ break
94
+
95
+ if not excluded:
96
+ yield ts_file
54
97
 
55
98
 
56
99
  # @shell_complexity: File reading with AST parsing and error handling
@@ -105,11 +148,21 @@ def scan_project(
105
148
  Yields:
106
149
  Result containing FileInfo or error message for each file
107
150
  """
151
+ # Get exclusion patterns once
152
+ exclude_result = get_exclude_paths(project_root)
153
+ exclude_patterns = exclude_result.unwrap() if isinstance(exclude_result, Success) else []
154
+
108
155
  if only_files is not None:
109
- # Phase 8.1: --changed mode - only scan specified files
156
+ # Phase 8.1: --changed mode - only scan specified files (with exclusions)
110
157
  for py_file in only_files:
111
158
  if py_file.exists() and py_file.suffix == ".py":
112
- yield read_and_parse_file(py_file, project_root)
159
+ # Apply exclusions even in --changed mode
160
+ try:
161
+ relative_str = str(py_file.relative_to(project_root))
162
+ except ValueError:
163
+ relative_str = str(py_file)
164
+ if not _is_excluded(relative_str, exclude_patterns):
165
+ yield read_and_parse_file(py_file, project_root)
113
166
  else:
114
- for py_file in discover_python_files(project_root):
167
+ for py_file in discover_python_files(project_root, exclude_patterns):
115
168
  yield read_and_parse_file(py_file, project_root)
invar/shell/pi_hooks.py CHANGED
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
16
16
  from jinja2 import Environment, FileSystemLoader
17
17
  from returns.result import Failure, Result, Success
18
18
 
19
+ from invar.core.language import detect_language_from_markers
19
20
  from invar.core.template_helpers import escape_for_js_template
20
21
  from invar.shell.claude_hooks import detect_syntax, get_invar_md_content
21
22
 
@@ -52,6 +53,10 @@ def generate_pi_hook_content(project_path: Path) -> Result[str, str]:
52
53
  syntax = detect_syntax(project_path)
53
54
  guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
54
55
 
56
+ # Detect project language from marker files
57
+ markers = frozenset(f.name for f in project_path.iterdir() if f.is_file())
58
+ language = detect_language_from_markers(markers)
59
+
55
60
  # Get and escape protocol content for JS template literal
56
61
  protocol_content = get_invar_md_content(project_path)
57
62
  protocol_escaped = escape_for_js_template(protocol_content)
@@ -61,6 +66,7 @@ def generate_pi_hook_content(project_path: Path) -> Result[str, str]:
61
66
  "protocol_version": PROTOCOL_VERSION,
62
67
  "generated_date": datetime.now().strftime("%Y-%m-%d"),
63
68
  "guard_cmd": guard_cmd,
69
+ "language": language,
64
70
  "invar_protocol_escaped": protocol_escaped,
65
71
  }
66
72