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.
@@ -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)
@@ -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)
@@ -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 "mcpServers" in existing and "invar" in existing.get("mcpServers", {}):
275
- return False # Already configured
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 (json.JSONDecodeError, OSError):
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
- mcp_json_path.write_text(json.dumps(mcp_content, indent=2))
286
- return True
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 Auto-select Claude Code (MCP + hooks + skills)
330
- - --pi Auto-select Pi (shares CLAUDE.md + skills, adds Pi hooks)
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
- if _configure_mcp(path):
480
- created.append(".mcp.json")
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):