invar-tools 1.7.1__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.
- invar/__init__.py +8 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/template_helpers.py +32 -0
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +307 -0
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/guard.py +36 -1
- invar/shell/commands/init.py +133 -7
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +77 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/fs.py +66 -13
- invar/shell/pi_hooks.py +213 -0
- invar/shell/prove/guard_ts.py +899 -0
- invar/shell/skill_manager.py +353 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +256 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +82 -0
- invar/templates/manifest.toml +8 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +98 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/investigate/SKILL.md.jinja +15 -0
- invar/templates/skills/propose/SKILL.md.jinja +33 -0
- invar/templates/skills/review/SKILL.md.jinja +346 -71
- {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/METADATA +326 -19
- invar_tools-1.10.0.dist-info/RECORD +173 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.7.1.dist-info/RECORD +0 -112
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.7.1.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 = {
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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/{
|
|
256
|
+
(f".claude/skills/{skill_name}/", desc)
|
|
235
257
|
)
|
|
236
258
|
else:
|
|
237
|
-
|
|
238
|
-
|
|
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"
|
|
@@ -260,6 +291,23 @@ def collect_removal_targets(path: Path) -> dict:
|
|
|
260
291
|
(f".claude/hooks/{hook_file.name}", "hook, has invar marker")
|
|
261
292
|
)
|
|
262
293
|
|
|
294
|
+
# Pi hooks (LX-04)
|
|
295
|
+
pi_hooks_dir = path / ".pi" / "hooks"
|
|
296
|
+
if pi_hooks_dir.exists():
|
|
297
|
+
invar_ts = pi_hooks_dir / "invar.ts"
|
|
298
|
+
if invar_ts.exists():
|
|
299
|
+
targets["delete_files"].append((".pi/hooks/invar.ts", "Pi hook"))
|
|
300
|
+
# Check if .pi/hooks is empty after removal
|
|
301
|
+
if not any(f for f in pi_hooks_dir.iterdir() if f.name != "invar.ts"):
|
|
302
|
+
targets["delete_dirs"].append((".pi/hooks/", "empty after removal"))
|
|
303
|
+
# Check if .pi is empty
|
|
304
|
+
pi_dir = path / ".pi"
|
|
305
|
+
hooks_only = all(
|
|
306
|
+
child.name == "hooks" for child in pi_dir.iterdir() if child.is_dir()
|
|
307
|
+
)
|
|
308
|
+
if hooks_only:
|
|
309
|
+
targets["delete_dirs"].append((".pi/", "only had hooks"))
|
|
310
|
+
|
|
263
311
|
# CLAUDE.md - delete if empty user region, otherwise modify
|
|
264
312
|
claude_md = path / "CLAUDE.md"
|
|
265
313
|
if claude_md.exists():
|
|
@@ -341,6 +389,14 @@ def show_preview(targets: dict) -> None:
|
|
|
341
389
|
for item, desc in targets["modify_files"]:
|
|
342
390
|
console.print(f" {item:40} ({desc})")
|
|
343
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
|
+
|
|
344
400
|
if targets["skip"]:
|
|
345
401
|
console.print("\n[dim]Will SKIP:[/dim]")
|
|
346
402
|
for item, desc in targets["skip"]:
|
|
@@ -429,16 +485,25 @@ def uninstall(
|
|
|
429
485
|
"-f",
|
|
430
486
|
help="Skip confirmation prompt",
|
|
431
487
|
),
|
|
488
|
+
remove_extensions: bool = typer.Option(
|
|
489
|
+
False,
|
|
490
|
+
"--remove-extensions",
|
|
491
|
+
help="Also remove extension skills (security, acceptance, etc.)",
|
|
492
|
+
),
|
|
432
493
|
) -> None:
|
|
433
494
|
"""Remove Invar from a project.
|
|
434
495
|
|
|
435
496
|
Safely removes Invar-generated files and configurations while
|
|
436
497
|
preserving user content. Uses marker-based detection.
|
|
437
498
|
|
|
499
|
+
By default, extension skills are preserved. Use --remove-extensions
|
|
500
|
+
to also remove them.
|
|
501
|
+
|
|
438
502
|
Examples:
|
|
439
|
-
invar uninstall --dry-run
|
|
440
|
-
invar uninstall
|
|
441
|
-
invar uninstall --force
|
|
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
|
|
442
507
|
"""
|
|
443
508
|
# Check if this is an Invar project
|
|
444
509
|
invar_toml = path / "invar.toml"
|
|
@@ -451,7 +516,7 @@ def uninstall(
|
|
|
451
516
|
raise typer.Exit(1)
|
|
452
517
|
|
|
453
518
|
# Collect targets
|
|
454
|
-
targets = collect_removal_targets(path)
|
|
519
|
+
targets = collect_removal_targets(path, remove_extensions=remove_extensions)
|
|
455
520
|
|
|
456
521
|
# Check if there's anything to do
|
|
457
522
|
if not any([targets["delete_dirs"], targets["delete_files"], targets["modify_files"]]):
|
invar/shell/commands/update.py
CHANGED
|
@@ -19,9 +19,7 @@ console = Console()
|
|
|
19
19
|
|
|
20
20
|
def update(
|
|
21
21
|
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
22
|
-
|
|
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 --
|
|
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
|
-
#
|
|
33
|
+
# Call init with matching parameters (DX-70 signature)
|
|
37
34
|
return init_command(
|
|
38
35
|
path=path,
|
|
39
36
|
claude=False,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
)
|
invar/shell/contract_coverage.py
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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)
|