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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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))
@@ -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
+ }