invar-tools 1.8.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.
Files changed (117) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/doc_edit.py +187 -0
  3. invar/core/doc_parser.py +563 -0
  4. invar/core/language.py +88 -0
  5. invar/core/models.py +106 -0
  6. invar/core/patterns/detector.py +6 -1
  7. invar/core/patterns/p0_exhaustive.py +15 -3
  8. invar/core/patterns/p0_literal.py +15 -3
  9. invar/core/patterns/p0_newtype.py +15 -3
  10. invar/core/patterns/p0_nonempty.py +15 -3
  11. invar/core/patterns/p0_validation.py +15 -3
  12. invar/core/patterns/registry.py +5 -1
  13. invar/core/patterns/types.py +5 -1
  14. invar/core/property_gen.py +4 -0
  15. invar/core/rules.py +84 -18
  16. invar/core/sync_helpers.py +27 -1
  17. invar/core/ts_parsers.py +286 -0
  18. invar/core/ts_sig_parser.py +310 -0
  19. invar/mcp/handlers.py +408 -0
  20. invar/mcp/server.py +288 -143
  21. invar/node_tools/MANIFEST +7 -0
  22. invar/node_tools/__init__.py +51 -0
  23. invar/node_tools/fc-runner/cli.js +77 -0
  24. invar/node_tools/quick-check/cli.js +28 -0
  25. invar/node_tools/ts-analyzer/cli.js +480 -0
  26. invar/shell/claude_hooks.py +35 -12
  27. invar/shell/commands/doc.py +409 -0
  28. invar/shell/commands/guard.py +41 -1
  29. invar/shell/commands/init.py +154 -16
  30. invar/shell/commands/perception.py +157 -33
  31. invar/shell/commands/skill.py +187 -0
  32. invar/shell/commands/template_sync.py +65 -13
  33. invar/shell/commands/uninstall.py +60 -12
  34. invar/shell/commands/update.py +6 -14
  35. invar/shell/contract_coverage.py +1 -0
  36. invar/shell/doc_tools.py +459 -0
  37. invar/shell/fs.py +67 -13
  38. invar/shell/pi_hooks.py +6 -0
  39. invar/shell/prove/crosshair.py +3 -0
  40. invar/shell/prove/guard_ts.py +902 -0
  41. invar/shell/skill_manager.py +355 -0
  42. invar/shell/template_engine.py +28 -4
  43. invar/shell/templates.py +4 -4
  44. invar/templates/claude-md/python/critical-rules.md +33 -0
  45. invar/templates/claude-md/python/quick-reference.md +24 -0
  46. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  47. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  48. invar/templates/claude-md/universal/check-in.md +25 -0
  49. invar/templates/claude-md/universal/skills.md +73 -0
  50. invar/templates/claude-md/universal/workflow.md +55 -0
  51. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  52. invar/templates/config/AGENT.md.jinja +58 -0
  53. invar/templates/config/CLAUDE.md.jinja +16 -209
  54. invar/templates/config/context.md.jinja +19 -0
  55. invar/templates/examples/{README.md → python/README.md} +2 -0
  56. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  57. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  58. invar/templates/examples/python/core_shell.py +227 -0
  59. invar/templates/examples/python/functional.py +613 -0
  60. invar/templates/examples/typescript/README.md +31 -0
  61. invar/templates/examples/typescript/contracts.ts +163 -0
  62. invar/templates/examples/typescript/core_shell.ts +374 -0
  63. invar/templates/examples/typescript/functional.ts +601 -0
  64. invar/templates/examples/typescript/workflow.md +95 -0
  65. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  66. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  67. invar/templates/hooks/Stop.sh.jinja +1 -1
  68. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  69. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  70. invar/templates/manifest.toml +7 -6
  71. invar/templates/onboard/assessment.md.jinja +214 -0
  72. invar/templates/onboard/patterns/python.md +347 -0
  73. invar/templates/onboard/patterns/typescript.md +452 -0
  74. invar/templates/onboard/roadmap.md.jinja +168 -0
  75. invar/templates/protocol/INVAR.md.jinja +51 -0
  76. invar/templates/protocol/python/architecture-examples.md +41 -0
  77. invar/templates/protocol/python/contracts-syntax.md +56 -0
  78. invar/templates/protocol/python/markers.md +44 -0
  79. invar/templates/protocol/python/tools.md +24 -0
  80. invar/templates/protocol/python/troubleshooting.md +38 -0
  81. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  82. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  83. invar/templates/protocol/typescript/markers.md +48 -0
  84. invar/templates/protocol/typescript/tools.md +65 -0
  85. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  86. invar/templates/protocol/universal/architecture.md +36 -0
  87. invar/templates/protocol/universal/completion.md +14 -0
  88. invar/templates/protocol/universal/contracts-concept.md +37 -0
  89. invar/templates/protocol/universal/header.md +17 -0
  90. invar/templates/protocol/universal/session.md +17 -0
  91. invar/templates/protocol/universal/six-laws.md +10 -0
  92. invar/templates/protocol/universal/usbv.md +14 -0
  93. invar/templates/protocol/universal/visible-workflow.md +25 -0
  94. invar/templates/skills/develop/SKILL.md.jinja +85 -3
  95. invar/templates/skills/extensions/_registry.yaml +93 -0
  96. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  97. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  98. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  99. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  100. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  101. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  102. invar/templates/skills/extensions/security/SKILL.md +382 -0
  103. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  104. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  105. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  106. invar/templates/skills/review/SKILL.md.jinja +220 -248
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
  108. invar_tools-1.11.0.dist-info/RECORD +178 -0
  109. invar/templates/examples/core_shell.py +0 -127
  110. invar/templates/protocol/INVAR.md +0 -310
  111. invar_tools-1.8.0.dist-info/RECORD +0 -116
  112. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  113. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
  114. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
  115. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
  116. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
  117. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
@@ -15,6 +15,8 @@ from typing import TYPE_CHECKING
15
15
  from jinja2 import Environment, FileSystemLoader
16
16
  from returns.result import Failure, Result, Success
17
17
 
18
+ from invar.core.language import detect_language_from_markers
19
+
18
20
  if TYPE_CHECKING:
19
21
  from rich.console import Console
20
22
 
@@ -32,6 +34,9 @@ DISABLED_MARKER = ".invar_disabled"
32
34
  # Marker for identifying Invar hooks in settings
33
35
  INVAR_HOOK_MARKER = ".claude/hooks/"
34
36
 
37
+ # Regex pattern for extracting protocol version from hook files
38
+ PROTOCOL_VERSION_PATTERN = r"Protocol: v([\d.]+)"
39
+
35
40
 
36
41
  # @shell_orchestration: Tightly coupled to Claude Code settings.local.json format
37
42
  def is_invar_hook(hook_entry: dict) -> bool:
@@ -102,11 +107,16 @@ def generate_hook_content(
102
107
  syntax = detect_syntax(project_path)
103
108
  guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
104
109
 
110
+ # Detect project language from marker files
111
+ markers = frozenset(f.name for f in project_path.iterdir() if f.is_file())
112
+ language = detect_language_from_markers(markers)
113
+
105
114
  # Build context for template
106
115
  context = {
107
116
  "protocol_version": PROTOCOL_VERSION,
108
117
  "generated_date": datetime.now().strftime("%Y-%m-%d"),
109
118
  "guard_cmd": guard_cmd,
119
+ "language": language,
110
120
  }
111
121
 
112
122
  # For UserPromptSubmit, add the full INVAR.md content
@@ -138,9 +148,11 @@ def _register_hooks_in_settings(project_path: Path) -> Result[bool, str]:
138
148
 
139
149
  def build_invar_hook(hook_type: str) -> dict:
140
150
  """Build Invar hook entry for a hook type."""
151
+ # Use $CLAUDE_PROJECT_DIR for portable paths that work regardless of cwd
152
+ # Claude Code provides this env var to all hooks (see code.claude.com/docs/hooks)
141
153
  hook_cmd = {
142
154
  "type": "command",
143
- "command": f".claude/hooks/{hook_type}.sh",
155
+ "command": f'"$CLAUDE_PROJECT_DIR"/.claude/hooks/{hook_type}.sh',
144
156
  }
145
157
  if hook_type in ("PreToolUse", "PostToolUse"):
146
158
  # These need a matcher - use "*" to match all tools
@@ -239,20 +251,24 @@ def install_claude_hooks(
239
251
  # Create merged wrapper
240
252
  merged = f'''#!/bin/bash
241
253
  # Merged hook: User + Invar (DX-57)
242
- # User hooks run first (higher priority)
243
-
244
- HOOK_DIR="$(dirname "$0")"
254
+ # Ensure correct working directory regardless of where Claude Code invokes from
255
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
256
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
257
+ if ! cd "$PROJECT_ROOT" 2>/dev/null; then
258
+ echo "[invar] Warning: Could not cd to $PROJECT_ROOT" >&2
259
+ exit 0 # Don't block Claude Code
260
+ fi
245
261
 
246
- # Run user hook first
247
- if [[ -f "$HOOK_DIR/{hook_type}.sh.user_backup" ]]; then
248
- source "$HOOK_DIR/{hook_type}.sh.user_backup" "$@"
262
+ # Run user hook first (higher priority)
263
+ if [[ -f "$SCRIPT_DIR/{hook_type}.sh.user_backup" ]]; then
264
+ source "$SCRIPT_DIR/{hook_type}.sh.user_backup" "$@"
249
265
  USER_EXIT=$?
250
266
  [[ $USER_EXIT -ne 0 ]] && exit $USER_EXIT
251
267
  fi
252
268
 
253
269
  # Run Invar hook
254
- if [[ -f "$HOOK_DIR/invar.{hook_type}.sh" ]]; then
255
- source "$HOOK_DIR/invar.{hook_type}.sh" "$@"
270
+ if [[ -f "$SCRIPT_DIR/invar.{hook_type}.sh" ]]; then
271
+ source "$SCRIPT_DIR/invar.{hook_type}.sh" "$@"
256
272
  fi
257
273
  '''
258
274
  wrapper_hook.write_text(merged)
@@ -262,7 +278,14 @@ fi
262
278
  # No user hook, create simple wrapper
263
279
  wrapper = f'''#!/bin/bash
264
280
  # Invar hook wrapper (DX-57)
265
- source "$(dirname "$0")/invar.{hook_type}.sh" "$@"
281
+ # Ensure correct working directory regardless of where Claude Code invokes from
282
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
283
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
284
+ if ! cd "$PROJECT_ROOT" 2>/dev/null; then
285
+ echo "[invar] Warning: Could not cd to $PROJECT_ROOT" >&2
286
+ exit 0 # Don't block Claude Code
287
+ fi
288
+ source "$SCRIPT_DIR/invar.{hook_type}.sh" "$@"
266
289
  '''
267
290
  wrapper_hook.write_text(wrapper)
268
291
  wrapper_hook.chmod(0o755)
@@ -311,7 +334,7 @@ def sync_claude_hooks(
311
334
  try:
312
335
  existing_content = check_hook.read_text()
313
336
  # Extract version from header comment
314
- version_match = re.search(r"Protocol: v([\d.]+)", existing_content)
337
+ version_match = re.search(PROTOCOL_VERSION_PATTERN, existing_content)
315
338
  old_version = version_match.group(1) if version_match else "unknown"
316
339
 
317
340
  if old_version != PROTOCOL_VERSION:
@@ -456,7 +479,7 @@ def hooks_status(
456
479
  # Try to get version
457
480
  try:
458
481
  content = invar_hook.read_text()
459
- match = re.search(r"Protocol: v([\d.]+)", content)
482
+ match = re.search(PROTOCOL_VERSION_PATTERN, content)
460
483
  if match:
461
484
  status[f"{hook_type}_version"] = match.group(1)
462
485
  except OSError:
@@ -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)
@@ -158,6 +163,8 @@ def guard(
158
163
  Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
159
164
  Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
160
165
  """
166
+ # LX-06: Language detection and dispatch
167
+ from invar.shell.commands.init import detect_language
161
168
  from invar.shell.guard_helpers import (
162
169
  collect_files_to_check,
163
170
  handle_changed_mode,
@@ -168,7 +175,38 @@ def guard(
168
175
  )
169
176
  from invar.shell.testing import VerificationLevel
170
177
 
171
- # DX-65: Handle single file mode
178
+ project_language = detect_language(path if path.is_dir() else find_project_root(path))
179
+
180
+ # Dispatch to language-specific guard if not Python
181
+ if project_language == "typescript":
182
+ from invar.shell.prove.guard_ts import run_typescript_guard
183
+
184
+ ts_result = run_typescript_guard(path if path.is_dir() else find_project_root(path))
185
+ match ts_result:
186
+ case Success(result):
187
+ if json_output or agent:
188
+ import json as json_mod
189
+
190
+ from invar.shell.prove.guard_ts import format_typescript_guard_v2
191
+
192
+ output = format_typescript_guard_v2(result)
193
+ console.print(json_mod.dumps(output, indent=2))
194
+ else:
195
+ console.print(f"[bold]TypeScript Guard[/bold] ({project_language})")
196
+ if result.status == "passed":
197
+ console.print("[green]✓ PASSED[/green]")
198
+ elif result.status == "skipped":
199
+ console.print("[yellow]⚠ SKIPPED[/yellow] (no TypeScript tools available)")
200
+ else:
201
+ console.print(f"[red]✗ FAILED[/red] ({result.error_count} errors)")
202
+ for v in result.violations[:10]: # Show first 10
203
+ console.print(f" {v.file}:{v.line}: [{v.severity}] {v.message}")
204
+ raise typer.Exit(0 if result.status == "passed" else 1)
205
+ case Failure(err):
206
+ console.print(f"[red]Error:[/red] {err}")
207
+ raise typer.Exit(1)
208
+
209
+ # DX-65: Handle single file mode (Python only from here)
172
210
  single_file_mode = path.is_file()
173
211
  single_file: Path | None = None
174
212
  if single_file_mode:
@@ -519,6 +557,7 @@ def rules(
519
557
  from invar.shell.commands.hooks import app as hooks_app # DX-57
520
558
  from invar.shell.commands.init import init
521
559
  from invar.shell.commands.mutate import mutate # DX-28
560
+ from invar.shell.commands.skill import app as skill_app # LX-07
522
561
  from invar.shell.commands.sync_self import sync_self # DX-49
523
562
  from invar.shell.commands.test import test, verify
524
563
  from invar.shell.commands.uninstall import uninstall # DX-69
@@ -531,6 +570,7 @@ app.command()(test)
531
570
  app.command()(verify)
532
571
  app.command()(mutate) # DX-28: Mutation testing
533
572
  app.add_typer(hooks_app, name="hooks") # DX-57: Claude Code hooks management
573
+ app.add_typer(skill_app, name="skill") # LX-07: Extension skills management
534
574
 
535
575
  # DX-56: Create dev subcommand group for developer commands
536
576
  dev_app = typer.Typer(