invar-tools 1.0.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 +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Guard output formatters.
|
|
3
|
+
|
|
4
|
+
Shell module: handles output formatting for guard command.
|
|
5
|
+
Extracted from cli.py to reduce file size.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from invar.core.formatter import format_guard_agent
|
|
13
|
+
from invar.core.models import GuardReport, Severity
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def show_file_context(file_path: str) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Show INSPECT section for a file (Phase 9.2 P14).
|
|
21
|
+
|
|
22
|
+
Displays file status and contract patterns to help agents understand context.
|
|
23
|
+
"""
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from invar.core.inspect import analyze_file_context
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
path = Path(file_path)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
source = path.read_text()
|
|
34
|
+
ctx = analyze_file_context(source, file_path, max_lines=500)
|
|
35
|
+
|
|
36
|
+
# Show compact INSPECT section
|
|
37
|
+
console.print(
|
|
38
|
+
f" [dim]INSPECT: {ctx.lines} lines ({ctx.percentage}% of limit), "
|
|
39
|
+
f"{ctx.functions_with_contracts}/{ctx.functions_total} functions with contracts[/dim]"
|
|
40
|
+
)
|
|
41
|
+
if ctx.contract_examples:
|
|
42
|
+
patterns = ", ".join(ctx.contract_examples[:2])
|
|
43
|
+
if len(patterns) > 60:
|
|
44
|
+
patterns = patterns[:57] + "..."
|
|
45
|
+
console.print(f" [dim]Patterns: {patterns}[/dim]")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass # Silently ignore errors in context display
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def output_rich(
|
|
51
|
+
report: GuardReport,
|
|
52
|
+
strict_pure: bool = False,
|
|
53
|
+
changed_mode: bool = False,
|
|
54
|
+
pedantic_mode: bool = False,
|
|
55
|
+
explain_mode: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Output report using Rich formatting."""
|
|
58
|
+
console.print("\n[bold]Invar Guard Report[/bold]")
|
|
59
|
+
console.print("=" * 40)
|
|
60
|
+
mode_info = [
|
|
61
|
+
m
|
|
62
|
+
for m, c in [
|
|
63
|
+
("strict-pure", strict_pure),
|
|
64
|
+
("changed-only", changed_mode),
|
|
65
|
+
("pedantic", pedantic_mode),
|
|
66
|
+
("explain", explain_mode),
|
|
67
|
+
]
|
|
68
|
+
if c
|
|
69
|
+
]
|
|
70
|
+
if mode_info:
|
|
71
|
+
console.print(f"[cyan]({', '.join(mode_info)} mode)[/cyan]")
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
if not report.violations:
|
|
75
|
+
console.print("[green]No violations found.[/green]")
|
|
76
|
+
else:
|
|
77
|
+
from invar.core.rule_meta import get_rule_meta
|
|
78
|
+
|
|
79
|
+
by_file: dict[str, list] = {}
|
|
80
|
+
for v in report.violations:
|
|
81
|
+
by_file.setdefault(v.file, []).append(v)
|
|
82
|
+
for fp, vs in sorted(by_file.items()):
|
|
83
|
+
console.print(f"[bold]{fp}[/bold]")
|
|
84
|
+
# Phase 9.2 P14: Show INSPECT section in --changed mode
|
|
85
|
+
if changed_mode:
|
|
86
|
+
show_file_context(fp)
|
|
87
|
+
for v in vs:
|
|
88
|
+
if v.severity == Severity.ERROR:
|
|
89
|
+
icon = "[red]ERROR[/red]"
|
|
90
|
+
elif v.severity == Severity.WARNING:
|
|
91
|
+
icon = "[yellow]WARN[/yellow]"
|
|
92
|
+
else:
|
|
93
|
+
icon = "[blue]INFO[/blue]"
|
|
94
|
+
ln = f":{v.line}" if v.line else ""
|
|
95
|
+
console.print(f" {icon} {ln} {v.message}")
|
|
96
|
+
# Show violation's suggestion if present (includes P25 extraction hints)
|
|
97
|
+
if v.suggestion:
|
|
98
|
+
# Handle multi-line suggestions (P25)
|
|
99
|
+
for line in v.suggestion.split("\n"):
|
|
100
|
+
console.print(f" [dim cyan]→ {line}[/dim cyan]")
|
|
101
|
+
else:
|
|
102
|
+
# Phase 9.2 P5: Fallback to hints from RULE_META
|
|
103
|
+
meta = get_rule_meta(v.rule)
|
|
104
|
+
if meta:
|
|
105
|
+
console.print(f" [dim cyan]→ {meta.hint}[/dim cyan]")
|
|
106
|
+
# --explain: show detailed information
|
|
107
|
+
if explain_mode:
|
|
108
|
+
console.print(f" [dim]Detects: {meta.detects}[/dim]")
|
|
109
|
+
if meta.cannot_detect:
|
|
110
|
+
console.print(
|
|
111
|
+
f" [dim]Cannot detect: {', '.join(meta.cannot_detect)}[/dim]"
|
|
112
|
+
)
|
|
113
|
+
console.print()
|
|
114
|
+
|
|
115
|
+
console.print("-" * 40)
|
|
116
|
+
summary = (
|
|
117
|
+
f"Files checked: {report.files_checked}\n"
|
|
118
|
+
f"Errors: {report.errors}\n"
|
|
119
|
+
f"Warnings: {report.warnings}"
|
|
120
|
+
)
|
|
121
|
+
if report.infos > 0:
|
|
122
|
+
summary += f"\nInfos: {report.infos}"
|
|
123
|
+
console.print(summary)
|
|
124
|
+
|
|
125
|
+
# P24: Contract coverage statistics (only show if core files exist)
|
|
126
|
+
if report.core_functions_total > 0:
|
|
127
|
+
pct = report.contract_coverage_pct
|
|
128
|
+
console.print(
|
|
129
|
+
f"\n[bold]Contract coverage:[/bold] {pct}% "
|
|
130
|
+
f"({report.core_functions_with_contracts}/{report.core_functions_total} functions)"
|
|
131
|
+
)
|
|
132
|
+
issues = report.contract_issue_counts
|
|
133
|
+
issue_parts = []
|
|
134
|
+
if issues["tautology"] > 0:
|
|
135
|
+
issue_parts.append(f"{issues['tautology']} tautology")
|
|
136
|
+
if issues["empty"] > 0:
|
|
137
|
+
issue_parts.append(f"{issues['empty']} empty")
|
|
138
|
+
if issues["partial"] > 0:
|
|
139
|
+
issue_parts.append(f"{issues['partial']} partial")
|
|
140
|
+
if issues["type_only"] > 0:
|
|
141
|
+
issue_parts.append(f"{issues['type_only']} type-check only")
|
|
142
|
+
if issue_parts:
|
|
143
|
+
console.print(f"[dim]Issues: {', '.join(issue_parts)}[/dim]")
|
|
144
|
+
|
|
145
|
+
# Code Health display (only when guard passes)
|
|
146
|
+
if report.passed and report.files_checked > 0:
|
|
147
|
+
# Calculate health: 100% for 0 warnings, decreases by 5% per warning, min 50%
|
|
148
|
+
health = max(50, 100 - report.warnings * 5)
|
|
149
|
+
bar_filled = health // 5 # 20 chars total
|
|
150
|
+
bar_empty = 20 - bar_filled
|
|
151
|
+
bar = "█" * bar_filled + "░" * bar_empty
|
|
152
|
+
|
|
153
|
+
if report.warnings == 0:
|
|
154
|
+
health_color = "green"
|
|
155
|
+
health_label = "Excellent"
|
|
156
|
+
elif report.warnings <= 2:
|
|
157
|
+
health_color = "green"
|
|
158
|
+
health_label = "Good"
|
|
159
|
+
elif report.warnings <= 5:
|
|
160
|
+
health_color = "yellow"
|
|
161
|
+
health_label = "Fair"
|
|
162
|
+
else:
|
|
163
|
+
health_color = "yellow"
|
|
164
|
+
health_label = "Needs attention"
|
|
165
|
+
|
|
166
|
+
console.print(
|
|
167
|
+
f"\n[bold]Code Health:[/bold] [{health_color}]{health}%[/{health_color}] "
|
|
168
|
+
f"{bar} ({health_label})"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Tip for fixing warnings
|
|
172
|
+
if report.warnings > 0:
|
|
173
|
+
console.print(
|
|
174
|
+
"[dim]💡 Fix warnings in files you modified to improve code health.[/dim]"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
console.print(
|
|
178
|
+
f"\n[{'green' if report.passed else 'red'}]Guard {'passed' if report.passed else 'failed'}.[/]"
|
|
179
|
+
)
|
|
180
|
+
console.print(
|
|
181
|
+
"\n[dim]Note: Guard performs static analysis only. "
|
|
182
|
+
"Dynamic imports and runtime behavior are not checked.[/dim]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def output_json(report: GuardReport) -> None:
|
|
187
|
+
"""Output report as JSON."""
|
|
188
|
+
import json
|
|
189
|
+
|
|
190
|
+
output = {
|
|
191
|
+
"files_checked": report.files_checked,
|
|
192
|
+
"errors": report.errors,
|
|
193
|
+
"warnings": report.warnings,
|
|
194
|
+
"infos": report.infos,
|
|
195
|
+
"passed": report.passed,
|
|
196
|
+
"violations": [v.model_dump() for v in report.violations],
|
|
197
|
+
}
|
|
198
|
+
console.print(json.dumps(output, indent=2))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def output_agent(
|
|
202
|
+
report: GuardReport,
|
|
203
|
+
doctest_passed: bool = True,
|
|
204
|
+
doctest_output: str = "",
|
|
205
|
+
crosshair_output: dict | None = None,
|
|
206
|
+
verification_level: str = "standard",
|
|
207
|
+
property_output: dict | None = None, # DX-08
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Output report in Agent-optimized JSON format (Phase 8.2 + DX-06 + DX-08 + DX-09).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
report: Guard analysis report
|
|
213
|
+
doctest_passed: Whether doctests passed
|
|
214
|
+
doctest_output: Doctest stdout (only if failed)
|
|
215
|
+
crosshair_output: CrossHair results dict
|
|
216
|
+
verification_level: Current level (static/standard)
|
|
217
|
+
property_output: Property test results dict (DX-08)
|
|
218
|
+
"""
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
output = format_guard_agent(report)
|
|
222
|
+
# DX-09: Add verification level for Agent transparency
|
|
223
|
+
output["verification_level"] = verification_level
|
|
224
|
+
# DX-06: Add doctest results to agent output
|
|
225
|
+
output["doctest"] = {
|
|
226
|
+
"passed": doctest_passed,
|
|
227
|
+
"output": doctest_output if not doctest_passed else "",
|
|
228
|
+
}
|
|
229
|
+
# DX-06: Add CrossHair results if available
|
|
230
|
+
if crosshair_output:
|
|
231
|
+
output["crosshair"] = crosshair_output
|
|
232
|
+
# DX-08: Add property test results if available
|
|
233
|
+
if property_output:
|
|
234
|
+
output["property_tests"] = property_output
|
|
235
|
+
console.print(json.dumps(output, indent=2))
|
invar/shell/init_cmd.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Init command for Invar.
|
|
3
|
+
|
|
4
|
+
Shell module: handles project initialization.
|
|
5
|
+
DX-21B: Added --claude flag for Claude Code integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from returns.result import Failure, Success
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from invar.shell.mcp_config import (
|
|
19
|
+
detect_available_methods,
|
|
20
|
+
generate_mcp_json,
|
|
21
|
+
get_method_by_name,
|
|
22
|
+
get_recommended_method,
|
|
23
|
+
)
|
|
24
|
+
from invar.shell.templates import (
|
|
25
|
+
add_config,
|
|
26
|
+
add_invar_reference,
|
|
27
|
+
copy_examples_directory,
|
|
28
|
+
copy_template,
|
|
29
|
+
create_agent_config,
|
|
30
|
+
create_directories,
|
|
31
|
+
detect_agent_configs,
|
|
32
|
+
install_hooks,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_claude_init(path: Path) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Run 'claude /init' to generate intelligent CLAUDE.md.
|
|
41
|
+
|
|
42
|
+
Returns True if successful, False otherwise.
|
|
43
|
+
"""
|
|
44
|
+
if not shutil.which("claude"):
|
|
45
|
+
console.print(
|
|
46
|
+
"[yellow]Warning:[/yellow] 'claude' CLI not found. "
|
|
47
|
+
"Install Claude Code: https://claude.ai/code"
|
|
48
|
+
)
|
|
49
|
+
console.print("[dim]Skipping claude /init, will create basic CLAUDE.md[/dim]")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
console.print("\n[bold]Running claude /init...[/bold]")
|
|
53
|
+
try:
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
["claude", "/init"],
|
|
56
|
+
cwd=path,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=120,
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
console.print("[green]claude /init completed successfully[/green]")
|
|
63
|
+
return True
|
|
64
|
+
else:
|
|
65
|
+
console.print(f"[yellow]Warning:[/yellow] claude /init failed: {result.stderr}")
|
|
66
|
+
return False
|
|
67
|
+
except subprocess.TimeoutExpired:
|
|
68
|
+
console.print("[yellow]Warning:[/yellow] claude /init timed out")
|
|
69
|
+
return False
|
|
70
|
+
except Exception as e:
|
|
71
|
+
console.print(f"[yellow]Warning:[/yellow] claude /init error: {e}")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def append_invar_reference_to_claude_md(path: Path) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Append Invar reference to existing CLAUDE.md.
|
|
78
|
+
|
|
79
|
+
Preserves content generated by 'claude /init'.
|
|
80
|
+
Returns True if modified, False otherwise.
|
|
81
|
+
"""
|
|
82
|
+
claude_md = path / "CLAUDE.md"
|
|
83
|
+
if not claude_md.exists():
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
content = claude_md.read_text()
|
|
87
|
+
if "INVAR.md" in content:
|
|
88
|
+
console.print("[dim]CLAUDE.md already references INVAR.md[/dim]")
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
# Append reference at the end
|
|
92
|
+
invar_reference = """
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Invar Protocol
|
|
97
|
+
|
|
98
|
+
> **Protocol:** Follow [INVAR.md](./INVAR.md) for the Invar development methodology.
|
|
99
|
+
|
|
100
|
+
Your **first message** for any implementation task MUST include actual output from:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
invar guard --changed # or: invar_guard(changed=true)
|
|
104
|
+
invar map --top 10 # or: invar_map(top=10)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Use MCP tools if available**, otherwise use CLI commands.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
claude_md.write_text(content + invar_reference)
|
|
111
|
+
console.print("[green]Updated[/green] CLAUDE.md (added Invar reference)")
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def configure_mcp_with_method(
|
|
116
|
+
path: Path, mcp_method: str | None
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Configure MCP server with specified or detected method."""
|
|
119
|
+
import json
|
|
120
|
+
|
|
121
|
+
# Determine method to use
|
|
122
|
+
if mcp_method:
|
|
123
|
+
config = get_method_by_name(mcp_method)
|
|
124
|
+
if config is None:
|
|
125
|
+
console.print(f"[yellow]Warning:[/yellow] Method '{mcp_method}' not available")
|
|
126
|
+
config = get_recommended_method()
|
|
127
|
+
console.print(f"[dim]Using fallback: {config.description}[/dim]")
|
|
128
|
+
else:
|
|
129
|
+
config = get_recommended_method()
|
|
130
|
+
|
|
131
|
+
console.print("\n[bold]Configuring MCP server...[/bold]")
|
|
132
|
+
console.print(f" Method: {config.description}")
|
|
133
|
+
|
|
134
|
+
# Generate and write .mcp.json
|
|
135
|
+
mcp_json_path = path / ".mcp.json"
|
|
136
|
+
mcp_content = generate_mcp_json(config)
|
|
137
|
+
|
|
138
|
+
if mcp_json_path.exists():
|
|
139
|
+
try:
|
|
140
|
+
existing = json.loads(mcp_json_path.read_text())
|
|
141
|
+
if "mcpServers" in existing and "invar" in existing.get("mcpServers", {}):
|
|
142
|
+
console.print("[dim]Skipped[/dim] .mcp.json (invar already configured)")
|
|
143
|
+
return
|
|
144
|
+
# Add invar to existing config
|
|
145
|
+
if "mcpServers" not in existing:
|
|
146
|
+
existing["mcpServers"] = {}
|
|
147
|
+
existing["mcpServers"]["invar"] = mcp_content["mcpServers"]["invar"]
|
|
148
|
+
mcp_json_path.write_text(json.dumps(existing, indent=2))
|
|
149
|
+
console.print("[green]Updated[/green] .mcp.json (added invar)")
|
|
150
|
+
except (json.JSONDecodeError, OSError):
|
|
151
|
+
console.print("[yellow]Warning:[/yellow] .mcp.json exists but couldn't update")
|
|
152
|
+
else:
|
|
153
|
+
mcp_json_path.write_text(json.dumps(mcp_content, indent=2))
|
|
154
|
+
console.print("[green]Created[/green] .mcp.json")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def show_available_mcp_methods() -> None:
|
|
158
|
+
"""Display available MCP execution methods."""
|
|
159
|
+
methods = detect_available_methods()
|
|
160
|
+
console.print("\n[bold]Available MCP methods:[/bold]")
|
|
161
|
+
for i, method in enumerate(methods):
|
|
162
|
+
marker = "[green]→[/green]" if i == 0 else " "
|
|
163
|
+
console.print(f" {marker} {method.method.value}: {method.description}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def init(
|
|
167
|
+
path: Path = typer.Argument(Path(), help="Project root directory"),
|
|
168
|
+
claude: bool = typer.Option(
|
|
169
|
+
False, "--claude", help="Run 'claude /init' and integrate with Claude Code"
|
|
170
|
+
),
|
|
171
|
+
mcp_method: str = typer.Option(
|
|
172
|
+
None,
|
|
173
|
+
"--mcp-method",
|
|
174
|
+
help="MCP execution method: uvx (recommended), command, or python",
|
|
175
|
+
),
|
|
176
|
+
dirs: bool = typer.Option(
|
|
177
|
+
None, "--dirs/--no-dirs", help="Create src/core and src/shell directories"
|
|
178
|
+
),
|
|
179
|
+
hooks: bool = typer.Option(
|
|
180
|
+
True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
|
|
181
|
+
),
|
|
182
|
+
yes: bool = typer.Option(
|
|
183
|
+
False, "--yes", "-y", help="Accept defaults without prompting"
|
|
184
|
+
),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Initialize Invar configuration in a project.
|
|
188
|
+
|
|
189
|
+
Works with or without pyproject.toml:
|
|
190
|
+
- If pyproject.toml exists: adds [tool.invar.guard] section
|
|
191
|
+
- Otherwise: creates invar.toml
|
|
192
|
+
|
|
193
|
+
Use --claude to run 'claude /init' first (recommended for Claude Code users).
|
|
194
|
+
Use --mcp-method to specify MCP execution method (uvx, command, python).
|
|
195
|
+
Use --dirs to always create directories, --no-dirs to skip.
|
|
196
|
+
Use --no-hooks to skip pre-commit hooks installation.
|
|
197
|
+
Use --yes to accept defaults without prompting.
|
|
198
|
+
"""
|
|
199
|
+
# DX-21B: Run claude /init if requested
|
|
200
|
+
if claude:
|
|
201
|
+
claude_success = run_claude_init(path)
|
|
202
|
+
if claude_success:
|
|
203
|
+
# Append Invar reference to generated CLAUDE.md
|
|
204
|
+
append_invar_reference_to_claude_md(path)
|
|
205
|
+
|
|
206
|
+
config_result = add_config(path, console)
|
|
207
|
+
if isinstance(config_result, Failure):
|
|
208
|
+
console.print(f"[red]Error:[/red] {config_result.failure()}")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
config_added = config_result.unwrap()
|
|
211
|
+
|
|
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
|
|
221
|
+
invar_dir = path / ".invar"
|
|
222
|
+
if not invar_dir.exists():
|
|
223
|
+
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
|
+
|
|
228
|
+
# Create proposals directory for protocol governance
|
|
229
|
+
proposals_dir = invar_dir / "proposals"
|
|
230
|
+
if not proposals_dir.exists():
|
|
231
|
+
proposals_dir.mkdir()
|
|
232
|
+
result = copy_template("proposal.md.template", proposals_dir, "TEMPLATE.md")
|
|
233
|
+
if isinstance(result, Success) and result.unwrap():
|
|
234
|
+
console.print("[green]Created[/green] .invar/proposals/TEMPLATE.md")
|
|
235
|
+
|
|
236
|
+
# Agent detection and configuration (DX-11)
|
|
237
|
+
console.print("\n[bold]Checking for agent configurations...[/bold]")
|
|
238
|
+
agent_result = detect_agent_configs(path)
|
|
239
|
+
if isinstance(agent_result, Failure):
|
|
240
|
+
console.print(f"[yellow]Warning:[/yellow] {agent_result.failure()}")
|
|
241
|
+
agent_status: dict[str, str] = {}
|
|
242
|
+
else:
|
|
243
|
+
agent_status = agent_result.unwrap()
|
|
244
|
+
|
|
245
|
+
# Handle agent configs (DX-11, DX-17)
|
|
246
|
+
for agent, status in agent_status.items():
|
|
247
|
+
if status == "configured":
|
|
248
|
+
console.print(f" [green]✓[/green] {agent}: already configured")
|
|
249
|
+
elif status == "found":
|
|
250
|
+
# Existing file without Invar reference - ask before modifying
|
|
251
|
+
if yes or typer.confirm(f" Add Invar reference to {agent} config?", default=True):
|
|
252
|
+
add_invar_reference(path, agent, console)
|
|
253
|
+
else:
|
|
254
|
+
console.print(f" [yellow]○[/yellow] {agent}: skipped")
|
|
255
|
+
elif status == "not_found":
|
|
256
|
+
# Create full template with workflow enforcement (DX-17)
|
|
257
|
+
create_agent_config(path, agent, console)
|
|
258
|
+
|
|
259
|
+
# Configure MCP server (DX-16, DX-21B)
|
|
260
|
+
configure_mcp_with_method(path, mcp_method)
|
|
261
|
+
|
|
262
|
+
# Show available methods if user might want to change
|
|
263
|
+
if not mcp_method and not yes:
|
|
264
|
+
show_available_mcp_methods()
|
|
265
|
+
|
|
266
|
+
# Create MCP setup guide
|
|
267
|
+
mcp_setup = invar_dir / "mcp-setup.md"
|
|
268
|
+
if not mcp_setup.exists():
|
|
269
|
+
from invar.shell.templates import _MCP_SETUP_TEMPLATE
|
|
270
|
+
mcp_setup.write_text(_MCP_SETUP_TEMPLATE)
|
|
271
|
+
console.print("[green]Created[/green] .invar/mcp-setup.md (setup guide)")
|
|
272
|
+
|
|
273
|
+
# Handle directory creation based on --dirs flag
|
|
274
|
+
if dirs is not False:
|
|
275
|
+
create_directories(path, console)
|
|
276
|
+
|
|
277
|
+
# Install pre-commit hooks if requested
|
|
278
|
+
if hooks:
|
|
279
|
+
install_hooks(path, console)
|
|
280
|
+
|
|
281
|
+
if not config_added and not (path / "INVAR.md").exists():
|
|
282
|
+
console.print("[yellow]Invar already configured.[/yellow]")
|
|
283
|
+
|
|
284
|
+
# Summary
|
|
285
|
+
console.print("\n[bold green]Invar initialized successfully![/bold green]")
|
|
286
|
+
if claude:
|
|
287
|
+
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]")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP configuration detection and generation.
|
|
3
|
+
|
|
4
|
+
Shell module: handles MCP server configuration for AI agents.
|
|
5
|
+
DX-21B: Smart detection of available MCP execution methods.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class McpMethod(Enum):
|
|
18
|
+
"""Available MCP execution methods."""
|
|
19
|
+
|
|
20
|
+
UVX = "uvx"
|
|
21
|
+
COMMAND = "command"
|
|
22
|
+
PYTHON = "python"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class McpExecConfig:
|
|
27
|
+
"""Configuration for MCP server execution.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> config = McpExecConfig(
|
|
31
|
+
... method=McpMethod.UVX,
|
|
32
|
+
... command="uvx",
|
|
33
|
+
... args=["invar-tools", "mcp"],
|
|
34
|
+
... description="uvx (recommended - isolated environment)",
|
|
35
|
+
... )
|
|
36
|
+
>>> config.to_mcp_json()
|
|
37
|
+
{'command': 'uvx', 'args': ['invar-tools', 'mcp']}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
method: McpMethod
|
|
41
|
+
command: str
|
|
42
|
+
args: list[str]
|
|
43
|
+
description: str
|
|
44
|
+
|
|
45
|
+
def to_mcp_json(self) -> dict[str, Any]:
|
|
46
|
+
"""Convert to MCP JSON format for .mcp.json."""
|
|
47
|
+
return {
|
|
48
|
+
"command": self.command,
|
|
49
|
+
"args": self.args,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_available_methods() -> list[McpExecConfig]:
|
|
54
|
+
"""
|
|
55
|
+
Detect available MCP execution methods, ordered by preference.
|
|
56
|
+
|
|
57
|
+
Returns a list of available methods, with the most preferred first.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
>>> methods = detect_available_methods()
|
|
61
|
+
>>> len(methods) >= 1 # At least Python fallback
|
|
62
|
+
True
|
|
63
|
+
>>> methods[-1].method == McpMethod.PYTHON # Python is always last
|
|
64
|
+
True
|
|
65
|
+
"""
|
|
66
|
+
methods: list[McpExecConfig] = []
|
|
67
|
+
|
|
68
|
+
# 1. uvx (recommended - isolated, auto-updates)
|
|
69
|
+
if shutil.which("uvx"):
|
|
70
|
+
methods.append(
|
|
71
|
+
McpExecConfig(
|
|
72
|
+
method=McpMethod.UVX,
|
|
73
|
+
command="uvx",
|
|
74
|
+
args=["invar-tools", "mcp"],
|
|
75
|
+
description="uvx (recommended - isolated environment)",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 2. invar command in PATH
|
|
80
|
+
if shutil.which("invar"):
|
|
81
|
+
methods.append(
|
|
82
|
+
McpExecConfig(
|
|
83
|
+
method=McpMethod.COMMAND,
|
|
84
|
+
command="invar",
|
|
85
|
+
args=["mcp"],
|
|
86
|
+
description="invar command (from PATH)",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# 3. Current Python (always available as fallback)
|
|
91
|
+
methods.append(
|
|
92
|
+
McpExecConfig(
|
|
93
|
+
method=McpMethod.PYTHON,
|
|
94
|
+
command=sys.executable,
|
|
95
|
+
args=["-m", "invar.mcp"],
|
|
96
|
+
description=f"Python ({sys.executable})",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return methods
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_recommended_method() -> McpExecConfig:
|
|
104
|
+
"""
|
|
105
|
+
Get the recommended MCP execution method.
|
|
106
|
+
|
|
107
|
+
Returns the first (most preferred) available method.
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> config = get_recommended_method()
|
|
111
|
+
>>> config.method in [McpMethod.UVX, McpMethod.COMMAND, McpMethod.PYTHON]
|
|
112
|
+
True
|
|
113
|
+
"""
|
|
114
|
+
methods = detect_available_methods()
|
|
115
|
+
return methods[0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_method_by_name(name: str) -> McpExecConfig | None:
|
|
119
|
+
"""
|
|
120
|
+
Get a specific MCP method by name.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
name: Method name ('uvx', 'command', or 'python')
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
McpExecConfig if the method is available, None otherwise.
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
>>> config = get_method_by_name("python")
|
|
130
|
+
>>> config is not None
|
|
131
|
+
True
|
|
132
|
+
>>> config.method == McpMethod.PYTHON
|
|
133
|
+
True
|
|
134
|
+
"""
|
|
135
|
+
methods = detect_available_methods()
|
|
136
|
+
for method in methods:
|
|
137
|
+
if method.method.value == name:
|
|
138
|
+
return method
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def generate_mcp_json(config: McpExecConfig | None = None) -> dict[str, Any]:
|
|
143
|
+
"""
|
|
144
|
+
Generate .mcp.json content for the given configuration.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
config: MCP execution config, or None to use recommended.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Dictionary suitable for writing to .mcp.json.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
>>> from invar.shell.mcp_config import generate_mcp_json, McpExecConfig, McpMethod
|
|
154
|
+
>>> config = McpExecConfig(
|
|
155
|
+
... method=McpMethod.UVX,
|
|
156
|
+
... command="uvx",
|
|
157
|
+
... args=["invar-tools", "mcp"],
|
|
158
|
+
... description="test",
|
|
159
|
+
... )
|
|
160
|
+
>>> result = generate_mcp_json(config)
|
|
161
|
+
>>> result["mcpServers"]["invar"]["command"]
|
|
162
|
+
'uvx'
|
|
163
|
+
"""
|
|
164
|
+
if config is None:
|
|
165
|
+
config = get_recommended_method()
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"mcpServers": {
|
|
169
|
+
"invar": config.to_mcp_json(),
|
|
170
|
+
}
|
|
171
|
+
}
|