invar-tools 1.10.0__py3-none-any.whl → 1.11.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/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/ts_sig_parser.py +6 -3
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +5 -0
- invar/shell/commands/init.py +72 -13
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +15 -14
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +13 -10
- invar/shell/skill_manager.py +17 -15
- invar/templates/skills/develop/SKILL.md.jinja +46 -0
- invar/templates/skills/review/SKILL.md.jinja +205 -493
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +34 -2
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/RECORD +22 -17
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.10.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for document tools.
|
|
3
|
+
|
|
4
|
+
DX-76: Structured document query and editing commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from returns.result import Success
|
|
16
|
+
|
|
17
|
+
from invar.shell.doc_tools import (
|
|
18
|
+
delete_section_content,
|
|
19
|
+
find_sections,
|
|
20
|
+
insert_section_content,
|
|
21
|
+
read_section,
|
|
22
|
+
read_toc,
|
|
23
|
+
replace_section_content,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Max content size for stdin reading (10MB) - matches parse_toc limit
|
|
27
|
+
MAX_STDIN_SIZE = 10_000_000
|
|
28
|
+
|
|
29
|
+
# Create doc subcommand app
|
|
30
|
+
doc_app = typer.Typer(
|
|
31
|
+
name="doc",
|
|
32
|
+
help="Structured document query and editing tools.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_stdin_limited() -> str:
|
|
38
|
+
"""Read from stdin with size limit to prevent OOM."""
|
|
39
|
+
content = sys.stdin.read(MAX_STDIN_SIZE + 1)
|
|
40
|
+
if len(content) > MAX_STDIN_SIZE:
|
|
41
|
+
raise typer.BadParameter(f"Input exceeds maximum size of {MAX_STDIN_SIZE} bytes")
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_file_limited(path: Path) -> str:
|
|
46
|
+
"""Read file with size limit to prevent OOM.
|
|
47
|
+
|
|
48
|
+
Uses single read to avoid TOCTOU race between stat and read.
|
|
49
|
+
"""
|
|
50
|
+
content = path.read_text(encoding="utf-8")
|
|
51
|
+
if len(content) > MAX_STDIN_SIZE:
|
|
52
|
+
raise typer.BadParameter(f"File {path} exceeds maximum size of {MAX_STDIN_SIZE} bytes")
|
|
53
|
+
return content
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# @shell_orchestration: CLI helper for glob pattern resolution
|
|
57
|
+
def _resolve_glob(pattern: str) -> list[Path]:
|
|
58
|
+
"""Resolve glob pattern to list of files."""
|
|
59
|
+
path = Path(pattern)
|
|
60
|
+
if path.exists() and path.is_file():
|
|
61
|
+
return [path]
|
|
62
|
+
# Try as glob pattern
|
|
63
|
+
if "*" in pattern or "?" in pattern:
|
|
64
|
+
# Handle ** for recursive
|
|
65
|
+
if "**" in pattern:
|
|
66
|
+
base = Path()
|
|
67
|
+
matches = list(base.glob(pattern))
|
|
68
|
+
else:
|
|
69
|
+
matches = list(Path().glob(pattern))
|
|
70
|
+
return [p for p in matches if p.is_file()]
|
|
71
|
+
# Single file that doesn't exist
|
|
72
|
+
return [path]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# @shell_orchestration: CLI output formatter for text mode
|
|
76
|
+
# @shell_complexity: Recursive formatting with depth filtering
|
|
77
|
+
def _format_toc_text(toc_data: dict, depth: int | None = None) -> str:
|
|
78
|
+
"""Format TOC as human-readable text."""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
|
|
81
|
+
if toc_data.get("frontmatter"):
|
|
82
|
+
fm = toc_data["frontmatter"]
|
|
83
|
+
lines.append(f"[frontmatter] ({fm['line_start']}-{fm['line_end']})")
|
|
84
|
+
|
|
85
|
+
def format_section(section: dict, current_depth: int = 1) -> None:
|
|
86
|
+
if depth is not None and section["level"] > depth:
|
|
87
|
+
return
|
|
88
|
+
indent = " " * (section["level"] - 1)
|
|
89
|
+
prefix = "#" * section["level"]
|
|
90
|
+
char_display = _format_size(section["char_count"])
|
|
91
|
+
lines.append(
|
|
92
|
+
f"{indent}{prefix} {section['title']} "
|
|
93
|
+
f"({section['line_start']}-{section['line_end']}, {char_display})"
|
|
94
|
+
)
|
|
95
|
+
for child in section.get("children", []):
|
|
96
|
+
format_section(child, current_depth + 1)
|
|
97
|
+
|
|
98
|
+
for section in toc_data.get("sections", []):
|
|
99
|
+
format_section(section)
|
|
100
|
+
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_size(chars: int) -> str:
|
|
105
|
+
"""Format character count as human-readable size."""
|
|
106
|
+
if chars >= 1000:
|
|
107
|
+
return f"{chars / 1000:.1f}K"
|
|
108
|
+
return f"{chars}B"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# @shell_orchestration: CLI helper for JSON serialization
|
|
112
|
+
def _section_to_dict(section) -> dict:
|
|
113
|
+
"""Convert Section to dict (recursive)."""
|
|
114
|
+
return {
|
|
115
|
+
"title": section.title,
|
|
116
|
+
"slug": section.slug,
|
|
117
|
+
"level": section.level,
|
|
118
|
+
"line_start": section.line_start,
|
|
119
|
+
"line_end": section.line_end,
|
|
120
|
+
"char_count": section.char_count,
|
|
121
|
+
"path": section.path,
|
|
122
|
+
"children": [_section_to_dict(c) for c in section.children],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# @shell_orchestration: CLI helper for depth filtering
|
|
127
|
+
def _filter_by_depth(sections: list[dict], max_depth: int) -> list[dict]:
|
|
128
|
+
"""Filter sections by maximum depth."""
|
|
129
|
+
result = []
|
|
130
|
+
for s in sections:
|
|
131
|
+
if s["level"] <= max_depth:
|
|
132
|
+
filtered = s.copy()
|
|
133
|
+
filtered["children"] = _filter_by_depth(s.get("children", []), max_depth)
|
|
134
|
+
result.append(filtered)
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# @invar:allow entry_point_too_thick: Multi-file glob + dual output format orchestration
|
|
139
|
+
@doc_app.command("toc")
|
|
140
|
+
def toc_command(
|
|
141
|
+
files: Annotated[
|
|
142
|
+
list[str],
|
|
143
|
+
typer.Argument(help="File(s) or glob pattern (e.g., 'docs/*.md')"),
|
|
144
|
+
],
|
|
145
|
+
depth: Annotated[
|
|
146
|
+
int | None,
|
|
147
|
+
typer.Option("--depth", "-d", help="Maximum heading depth (1-6)"),
|
|
148
|
+
] = None,
|
|
149
|
+
output_format: Annotated[
|
|
150
|
+
str,
|
|
151
|
+
typer.Option("--format", "-f", help="Output format: json or text"),
|
|
152
|
+
] = "json",
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Extract document structure (Table of Contents).
|
|
155
|
+
|
|
156
|
+
Shows headings hierarchy with line numbers and character counts.
|
|
157
|
+
"""
|
|
158
|
+
all_results: list[dict] = []
|
|
159
|
+
has_error = False
|
|
160
|
+
|
|
161
|
+
for pattern in files:
|
|
162
|
+
resolved = _resolve_glob(pattern)
|
|
163
|
+
if not resolved or (len(resolved) == 1 and not resolved[0].exists()):
|
|
164
|
+
typer.echo(f"Error: No files found matching '{pattern}'", err=True)
|
|
165
|
+
has_error = True
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
for path in resolved:
|
|
169
|
+
result = read_toc(path)
|
|
170
|
+
if isinstance(result, Success):
|
|
171
|
+
toc = result.unwrap()
|
|
172
|
+
from dataclasses import asdict
|
|
173
|
+
|
|
174
|
+
toc_dict = {
|
|
175
|
+
"file": str(path),
|
|
176
|
+
"sections": [_section_to_dict(s) for s in toc.sections],
|
|
177
|
+
"frontmatter": asdict(toc.frontmatter) if toc.frontmatter else None,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Apply depth filter if specified
|
|
181
|
+
if depth is not None:
|
|
182
|
+
toc_dict["sections"] = _filter_by_depth(toc_dict["sections"], depth)
|
|
183
|
+
|
|
184
|
+
all_results.append(toc_dict)
|
|
185
|
+
else:
|
|
186
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
187
|
+
has_error = True
|
|
188
|
+
|
|
189
|
+
if not all_results:
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
|
|
192
|
+
# Output
|
|
193
|
+
if output_format == "text":
|
|
194
|
+
for toc_data in all_results:
|
|
195
|
+
if len(all_results) > 1:
|
|
196
|
+
typer.echo(f"\n=== {toc_data['file']} ===")
|
|
197
|
+
typer.echo(_format_toc_text(toc_data, depth))
|
|
198
|
+
else:
|
|
199
|
+
# JSON output
|
|
200
|
+
if len(all_results) == 1:
|
|
201
|
+
typer.echo(json.dumps(all_results[0], indent=2))
|
|
202
|
+
else:
|
|
203
|
+
typer.echo(json.dumps({"files": all_results}, indent=2))
|
|
204
|
+
|
|
205
|
+
if has_error:
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# @invar:allow entry_point_too_thick: Section addressing + output format orchestration
|
|
210
|
+
@doc_app.command("read")
|
|
211
|
+
def read_command(
|
|
212
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
213
|
+
section: Annotated[str, typer.Argument(help="Section path (slug, fuzzy, index, or @line)")],
|
|
214
|
+
include_children: Annotated[
|
|
215
|
+
bool,
|
|
216
|
+
typer.Option("--children/--no-children", help="Include child sections"),
|
|
217
|
+
] = True,
|
|
218
|
+
json_output: Annotated[
|
|
219
|
+
bool,
|
|
220
|
+
typer.Option("--json", "-j", help="Output as JSON"),
|
|
221
|
+
] = False,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Read a specific section from a document.
|
|
224
|
+
|
|
225
|
+
Section addressing:
|
|
226
|
+
- Slug path: "requirements/auth"
|
|
227
|
+
- Fuzzy: "auth" (matches first containing)
|
|
228
|
+
- Index: "#0/#1" (positional)
|
|
229
|
+
- Line anchor: "@48" (section at line 48)
|
|
230
|
+
"""
|
|
231
|
+
result = read_section(file, section, include_children=include_children)
|
|
232
|
+
|
|
233
|
+
if isinstance(result, Success):
|
|
234
|
+
content = result.unwrap()
|
|
235
|
+
if json_output:
|
|
236
|
+
typer.echo(json.dumps({"path": section, "content": content}, indent=2))
|
|
237
|
+
else:
|
|
238
|
+
typer.echo(content)
|
|
239
|
+
else:
|
|
240
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# @invar:allow entry_point_too_thick: Multi-file glob + pattern/level filtering orchestration
|
|
245
|
+
@doc_app.command("find")
|
|
246
|
+
def find_command(
|
|
247
|
+
pattern: Annotated[str, typer.Argument(help="Title pattern (glob-style, e.g., '*auth*')")],
|
|
248
|
+
files: Annotated[
|
|
249
|
+
list[str],
|
|
250
|
+
typer.Argument(help="File(s) or glob pattern"),
|
|
251
|
+
],
|
|
252
|
+
content: Annotated[
|
|
253
|
+
str | None,
|
|
254
|
+
typer.Option("--content", "-c", help="Content search pattern"),
|
|
255
|
+
] = None,
|
|
256
|
+
level: Annotated[
|
|
257
|
+
int | None,
|
|
258
|
+
typer.Option("--level", "-l", help="Filter by heading level (1-6)"),
|
|
259
|
+
] = None,
|
|
260
|
+
json_output: Annotated[
|
|
261
|
+
bool,
|
|
262
|
+
typer.Option("--json", "-j", help="Output as JSON"),
|
|
263
|
+
] = True,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Find sections matching a pattern.
|
|
266
|
+
|
|
267
|
+
Supports glob patterns for titles and optional content search.
|
|
268
|
+
"""
|
|
269
|
+
all_matches: list[dict] = []
|
|
270
|
+
has_error = False
|
|
271
|
+
|
|
272
|
+
for file_pattern in files:
|
|
273
|
+
resolved = _resolve_glob(file_pattern)
|
|
274
|
+
if not resolved or (len(resolved) == 1 and not resolved[0].exists()):
|
|
275
|
+
typer.echo(f"Error: No files found matching '{file_pattern}'", err=True)
|
|
276
|
+
has_error = True
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
for path in resolved:
|
|
280
|
+
result = find_sections(path, pattern, content, level=level)
|
|
281
|
+
if isinstance(result, Success):
|
|
282
|
+
sections = result.unwrap()
|
|
283
|
+
for s in sections:
|
|
284
|
+
all_matches.append({
|
|
285
|
+
"file": str(path),
|
|
286
|
+
"path": s.path,
|
|
287
|
+
"title": s.title,
|
|
288
|
+
"level": s.level,
|
|
289
|
+
"line_start": s.line_start,
|
|
290
|
+
"line_end": s.line_end,
|
|
291
|
+
"char_count": s.char_count,
|
|
292
|
+
})
|
|
293
|
+
else:
|
|
294
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
295
|
+
has_error = True
|
|
296
|
+
|
|
297
|
+
if json_output:
|
|
298
|
+
typer.echo(json.dumps({"matches": all_matches}, indent=2))
|
|
299
|
+
else:
|
|
300
|
+
for m in all_matches:
|
|
301
|
+
typer.echo(f"{m['file']}:{m['line_start']} {m['path']} ({m['char_count']}B)")
|
|
302
|
+
|
|
303
|
+
if has_error:
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# @invar:allow entry_point_too_thick: Content input + section replacement orchestration
|
|
308
|
+
@doc_app.command("replace")
|
|
309
|
+
def replace_command(
|
|
310
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
311
|
+
section: Annotated[str, typer.Argument(help="Section path to replace")],
|
|
312
|
+
content_file: Annotated[
|
|
313
|
+
Path | None,
|
|
314
|
+
typer.Option("--content", "-c", help="File containing new content (use - for stdin)"),
|
|
315
|
+
] = None,
|
|
316
|
+
keep_heading: Annotated[
|
|
317
|
+
bool,
|
|
318
|
+
typer.Option("--keep-heading/--no-keep-heading", help="Preserve original heading"),
|
|
319
|
+
] = True,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Replace a section's content.
|
|
322
|
+
|
|
323
|
+
Content can be provided via --content file or stdin.
|
|
324
|
+
"""
|
|
325
|
+
# Read content from file or stdin
|
|
326
|
+
if content_file is None:
|
|
327
|
+
typer.echo("Reading content from stdin (Ctrl+D to end)...", err=True)
|
|
328
|
+
new_content = _read_stdin_limited()
|
|
329
|
+
elif str(content_file) == "-":
|
|
330
|
+
new_content = _read_stdin_limited()
|
|
331
|
+
else:
|
|
332
|
+
new_content = _read_file_limited(content_file)
|
|
333
|
+
|
|
334
|
+
result = replace_section_content(file, section, new_content, keep_heading)
|
|
335
|
+
|
|
336
|
+
if isinstance(result, Success):
|
|
337
|
+
info = result.unwrap()
|
|
338
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
339
|
+
else:
|
|
340
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
341
|
+
raise typer.Exit(1)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# @invar:allow entry_point_too_thick: Content input + position-based insertion orchestration
|
|
345
|
+
@doc_app.command("insert")
|
|
346
|
+
def insert_command(
|
|
347
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
348
|
+
anchor: Annotated[str, typer.Argument(help="Section path for anchor")],
|
|
349
|
+
content_file: Annotated[
|
|
350
|
+
Path | None,
|
|
351
|
+
typer.Option("--content", "-c", help="File containing content to insert"),
|
|
352
|
+
] = None,
|
|
353
|
+
position: Annotated[
|
|
354
|
+
str,
|
|
355
|
+
typer.Option("--position", "-p", help="Where to insert: before, after, first_child, last_child"),
|
|
356
|
+
] = "after",
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Insert new content relative to a section.
|
|
359
|
+
|
|
360
|
+
Content should include heading if adding a new section.
|
|
361
|
+
"""
|
|
362
|
+
valid_positions = ("before", "after", "first_child", "last_child")
|
|
363
|
+
if position not in valid_positions:
|
|
364
|
+
typer.echo(f"Error: position must be one of {valid_positions}", err=True)
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
|
|
367
|
+
# Read content from file or stdin
|
|
368
|
+
if content_file is None:
|
|
369
|
+
typer.echo("Reading content from stdin (Ctrl+D to end)...", err=True)
|
|
370
|
+
content = _read_stdin_limited()
|
|
371
|
+
elif str(content_file) == "-":
|
|
372
|
+
content = _read_stdin_limited()
|
|
373
|
+
else:
|
|
374
|
+
content = _read_file_limited(content_file)
|
|
375
|
+
|
|
376
|
+
from typing import Literal
|
|
377
|
+
pos: Literal["before", "after", "first_child", "last_child"] = position # type: ignore[assignment]
|
|
378
|
+
result = insert_section_content(file, anchor, content, pos)
|
|
379
|
+
|
|
380
|
+
if isinstance(result, Success):
|
|
381
|
+
info = result.unwrap()
|
|
382
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
383
|
+
else:
|
|
384
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
385
|
+
raise typer.Exit(1)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# @invar:allow entry_point_too_thick: Section deletion with children handling
|
|
389
|
+
@doc_app.command("delete")
|
|
390
|
+
def delete_command(
|
|
391
|
+
file: Annotated[Path, typer.Argument(help="Path to markdown file")],
|
|
392
|
+
section: Annotated[str, typer.Argument(help="Section path to delete")],
|
|
393
|
+
include_children: Annotated[
|
|
394
|
+
bool,
|
|
395
|
+
typer.Option("--children/--no-children", help="Include child sections in deletion"),
|
|
396
|
+
] = True,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Delete a section from a document.
|
|
399
|
+
|
|
400
|
+
Removes the heading and all content until the next same-level heading.
|
|
401
|
+
"""
|
|
402
|
+
result = delete_section_content(file, section, include_children=include_children)
|
|
403
|
+
|
|
404
|
+
if isinstance(result, Success):
|
|
405
|
+
info = result.unwrap()
|
|
406
|
+
typer.echo(json.dumps({"success": True, **info}, indent=2))
|
|
407
|
+
else:
|
|
408
|
+
typer.echo(f"Error: {result.failure()}", err=True)
|
|
409
|
+
raise typer.Exit(1)
|
invar/shell/commands/guard.py
CHANGED
|
@@ -36,6 +36,11 @@ app = typer.Typer(
|
|
|
36
36
|
)
|
|
37
37
|
console = Console()
|
|
38
38
|
|
|
39
|
+
# DX-76: Register doc subcommand
|
|
40
|
+
from invar.shell.commands.doc import doc_app
|
|
41
|
+
|
|
42
|
+
app.add_typer(doc_app, name="doc")
|
|
43
|
+
|
|
39
44
|
|
|
40
45
|
# @shell_orchestration: Statistics helper for CLI guard output
|
|
41
46
|
# @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
|
invar/shell/commands/init.py
CHANGED
|
@@ -260,8 +260,12 @@ def _show_execution_output(
|
|
|
260
260
|
|
|
261
261
|
|
|
262
262
|
# @shell_complexity: MCP config merge with existing file handling
|
|
263
|
-
def _configure_mcp(path: Path) -> bool:
|
|
264
|
-
"""Configure MCP server with recommended method.
|
|
263
|
+
def _configure_mcp(path: Path) -> tuple[bool, str]:
|
|
264
|
+
"""Configure MCP server with recommended method.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
(success, message): (True, "created") | (True, "merged") | (False, "already_configured") | (False, error_message)
|
|
268
|
+
"""
|
|
265
269
|
import json
|
|
266
270
|
|
|
267
271
|
config = get_recommended_method()
|
|
@@ -271,19 +275,24 @@ def _configure_mcp(path: Path) -> bool:
|
|
|
271
275
|
if mcp_json_path.exists():
|
|
272
276
|
try:
|
|
273
277
|
existing = json.loads(mcp_json_path.read_text())
|
|
274
|
-
if
|
|
275
|
-
return False
|
|
278
|
+
if existing.get("mcpServers", {}).get("invar"):
|
|
279
|
+
return (False, "already_configured")
|
|
276
280
|
# Add invar to existing config
|
|
277
281
|
if "mcpServers" not in existing:
|
|
278
282
|
existing["mcpServers"] = {}
|
|
279
283
|
existing["mcpServers"]["invar"] = mcp_content["mcpServers"]["invar"]
|
|
280
284
|
mcp_json_path.write_text(json.dumps(existing, indent=2))
|
|
281
|
-
return True
|
|
282
|
-
except
|
|
283
|
-
return False
|
|
285
|
+
return (True, "merged")
|
|
286
|
+
except json.JSONDecodeError as e:
|
|
287
|
+
return (False, f"Invalid JSON in .mcp.json: {e}")
|
|
288
|
+
except OSError as e:
|
|
289
|
+
return (False, f"Failed to read/write .mcp.json: {e}")
|
|
284
290
|
else:
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
try:
|
|
292
|
+
mcp_json_path.write_text(json.dumps(mcp_content, indent=2))
|
|
293
|
+
return (True, "created")
|
|
294
|
+
except OSError as e:
|
|
295
|
+
return (False, f"Failed to create .mcp.json: {e}")
|
|
287
296
|
|
|
288
297
|
|
|
289
298
|
# =============================================================================
|
|
@@ -307,6 +316,11 @@ def init(
|
|
|
307
316
|
"--pi",
|
|
308
317
|
help="Auto-select Pi Coding Agent, skip all prompts",
|
|
309
318
|
),
|
|
319
|
+
mcp_only: bool = typer.Option(
|
|
320
|
+
False,
|
|
321
|
+
"--mcp-only",
|
|
322
|
+
help="Install MCP tools only (no framework files, just .mcp.json)",
|
|
323
|
+
),
|
|
310
324
|
language: str | None = typer.Option(
|
|
311
325
|
None,
|
|
312
326
|
"--language",
|
|
@@ -326,8 +340,9 @@ def init(
|
|
|
326
340
|
|
|
327
341
|
\b
|
|
328
342
|
Quick setup options:
|
|
329
|
-
- --claude
|
|
330
|
-
- --pi
|
|
343
|
+
- --claude Auto-select Claude Code (MCP + hooks + skills)
|
|
344
|
+
- --pi Auto-select Pi (shares CLAUDE.md + skills, adds Pi hooks)
|
|
345
|
+
- --mcp-only Install MCP tools only (minimal, no framework files)
|
|
331
346
|
|
|
332
347
|
\b
|
|
333
348
|
This command is safe - it always MERGES with existing files:
|
|
@@ -346,11 +361,49 @@ def init(
|
|
|
346
361
|
console.print("[red]Error:[/red] Cannot use --claude and --pi together.")
|
|
347
362
|
raise typer.Exit(1)
|
|
348
363
|
|
|
364
|
+
if mcp_only and (claude or pi):
|
|
365
|
+
console.print("[red]Error:[/red] --mcp-only cannot be combined with --claude or --pi.")
|
|
366
|
+
raise typer.Exit(1)
|
|
367
|
+
|
|
368
|
+
if mcp_only and language is not None:
|
|
369
|
+
console.print("[red]Error:[/red] --language is not needed with --mcp-only (MCP tools work for all languages).")
|
|
370
|
+
raise typer.Exit(1)
|
|
371
|
+
|
|
349
372
|
# Resolve path
|
|
350
373
|
if path == Path():
|
|
351
374
|
path = Path.cwd()
|
|
352
375
|
path = path.resolve()
|
|
353
376
|
|
|
377
|
+
# MCP-only mode: minimal setup, just create .mcp.json
|
|
378
|
+
if mcp_only:
|
|
379
|
+
console.print(f"\n[bold]Invar v{__version__} - MCP Tools Only[/bold]")
|
|
380
|
+
console.print("=" * 45)
|
|
381
|
+
console.print("[dim]Installing MCP server configuration only.[/dim]\n")
|
|
382
|
+
|
|
383
|
+
# Preview mode
|
|
384
|
+
if preview:
|
|
385
|
+
console.print("[bold]Preview - Would create:[/bold]")
|
|
386
|
+
console.print(" [green]✓[/green] .mcp.json")
|
|
387
|
+
console.print("\n[dim]Run without --preview to apply.[/dim]")
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
console.print("[bold]Creating .mcp.json...[/bold]")
|
|
391
|
+
success, message = _configure_mcp(path)
|
|
392
|
+
if success:
|
|
393
|
+
if message == "created":
|
|
394
|
+
console.print("[green]✓[/green] Created .mcp.json")
|
|
395
|
+
elif message == "merged":
|
|
396
|
+
console.print("[green]✓[/green] Merged into existing .mcp.json")
|
|
397
|
+
console.print("\n[bold]Setup complete![/bold]")
|
|
398
|
+
console.print("MCP tools available: invar_doc_*, invar_sig, invar_map, invar_guard")
|
|
399
|
+
elif message == "already_configured":
|
|
400
|
+
console.print("[yellow]○[/yellow] .mcp.json already configured")
|
|
401
|
+
else:
|
|
402
|
+
console.print(f"[red]Error:[/red] {message}")
|
|
403
|
+
raise typer.Exit(1)
|
|
404
|
+
|
|
405
|
+
return # Early exit, skip all framework setup
|
|
406
|
+
|
|
354
407
|
# LX-05: Language detection and validation
|
|
355
408
|
if language is None:
|
|
356
409
|
detected = detect_language(path)
|
|
@@ -476,8 +529,14 @@ def init(
|
|
|
476
529
|
|
|
477
530
|
# Configure MCP if Claude selected
|
|
478
531
|
if "claude" in agents and selected_files.get(".mcp.json", True):
|
|
479
|
-
|
|
480
|
-
|
|
532
|
+
success, message = _configure_mcp(path)
|
|
533
|
+
if success:
|
|
534
|
+
if message == "created":
|
|
535
|
+
created.append(".mcp.json")
|
|
536
|
+
elif message == "merged":
|
|
537
|
+
merged.append(".mcp.json")
|
|
538
|
+
elif message != "already_configured":
|
|
539
|
+
console.print(f"[yellow]Warning:[/yellow] MCP configuration failed: {message}")
|
|
481
540
|
|
|
482
541
|
# Create directories if selected
|
|
483
542
|
if selected_files.get("src/core/", True):
|