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,7 +15,7 @@ from returns.result import Failure, Success
15
15
  from rich.console import Console
16
16
  from rich.panel import Panel
17
17
 
18
- from invar.core.sync_helpers import SyncConfig
18
+ from invar.core.sync_helpers import VALID_LANGUAGES, SyncConfig
19
19
  from invar.shell.claude_hooks import install_claude_hooks
20
20
  from invar.shell.commands.template_sync import sync_templates
21
21
  from invar.shell.mcp_config import (
@@ -70,6 +70,58 @@ AGENT_CONFIGS: dict[str, dict[str, str]] = {
70
70
  }
71
71
 
72
72
 
73
+ # =============================================================================
74
+ # Language Detection (LX-05)
75
+ # =============================================================================
76
+
77
+ from invar.core.language import (
78
+ FUTURE_LANGUAGES,
79
+ detect_language_from_markers,
80
+ )
81
+
82
+ # Marker files to check for language detection
83
+ LANGUAGE_MARKERS: frozenset[str] = frozenset({
84
+ "pyproject.toml", "setup.py", # Python
85
+ "tsconfig.json", "package.json", # TypeScript
86
+ "Cargo.toml", # Rust (future)
87
+ "go.mod", # Go (future)
88
+ })
89
+
90
+
91
+ def detect_language(path: Path) -> str:
92
+ """Detect project language from marker files (Shell wrapper).
93
+
94
+ This is the Shell wrapper that handles I/O. The actual detection
95
+ logic is in core.language.detect_language_from_markers.
96
+
97
+ Examples:
98
+ >>> from pathlib import Path
99
+ >>> import tempfile
100
+ >>> with tempfile.TemporaryDirectory() as d:
101
+ ... p = Path(d)
102
+ ... (p / "pyproject.toml").touch()
103
+ ... detect_language(p)
104
+ 'python'
105
+
106
+ >>> with tempfile.TemporaryDirectory() as d:
107
+ ... p = Path(d)
108
+ ... (p / "tsconfig.json").touch()
109
+ ... detect_language(p)
110
+ 'typescript'
111
+
112
+ >>> with tempfile.TemporaryDirectory() as d:
113
+ ... p = Path(d)
114
+ ... detect_language(p) # Empty dir defaults to python
115
+ 'python'
116
+ """
117
+ # Collect present markers (I/O operation)
118
+ present_markers = frozenset(
119
+ marker for marker in LANGUAGE_MARKERS if (path / marker).exists()
120
+ )
121
+ # Delegate to pure core function
122
+ return detect_language_from_markers(present_markers)
123
+
124
+
73
125
  # =============================================================================
74
126
  # Interactive Prompts (DX-70)
75
127
  # =============================================================================
@@ -208,8 +260,12 @@ def _show_execution_output(
208
260
 
209
261
 
210
262
  # @shell_complexity: MCP config merge with existing file handling
211
- def _configure_mcp(path: Path) -> bool:
212
- """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
+ """
213
269
  import json
214
270
 
215
271
  config = get_recommended_method()
@@ -219,19 +275,24 @@ def _configure_mcp(path: Path) -> bool:
219
275
  if mcp_json_path.exists():
220
276
  try:
221
277
  existing = json.loads(mcp_json_path.read_text())
222
- if "mcpServers" in existing and "invar" in existing.get("mcpServers", {}):
223
- return False # Already configured
278
+ if existing.get("mcpServers", {}).get("invar"):
279
+ return (False, "already_configured")
224
280
  # Add invar to existing config
225
281
  if "mcpServers" not in existing:
226
282
  existing["mcpServers"] = {}
227
283
  existing["mcpServers"]["invar"] = mcp_content["mcpServers"]["invar"]
228
284
  mcp_json_path.write_text(json.dumps(existing, indent=2))
229
- return True
230
- except (json.JSONDecodeError, OSError):
231
- 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}")
232
290
  else:
233
- mcp_json_path.write_text(json.dumps(mcp_content, indent=2))
234
- 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}")
235
296
 
236
297
 
237
298
  # =============================================================================
@@ -255,6 +316,17 @@ def init(
255
316
  "--pi",
256
317
  help="Auto-select Pi Coding Agent, skip all prompts",
257
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
+ ),
324
+ language: str | None = typer.Option(
325
+ None,
326
+ "--language",
327
+ "-l",
328
+ help="Target language (auto-detected if not specified): python, typescript",
329
+ ),
258
330
  preview: bool = typer.Option(
259
331
  False,
260
332
  "--preview",
@@ -268,8 +340,9 @@ def init(
268
340
 
269
341
  \b
270
342
  Quick setup options:
271
- - --claude Auto-select Claude Code (MCP + hooks + skills)
272
- - --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)
273
346
 
274
347
  \b
275
348
  This command is safe - it always MERGES with existing files:
@@ -288,11 +361,69 @@ def init(
288
361
  console.print("[red]Error:[/red] Cannot use --claude and --pi together.")
289
362
  raise typer.Exit(1)
290
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
+
291
372
  # Resolve path
292
373
  if path == Path():
293
374
  path = Path.cwd()
294
375
  path = path.resolve()
295
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
+
407
+ # LX-05: Language detection and validation
408
+ if language is None:
409
+ detected = detect_language(path)
410
+ # Fall back to python for unsupported detected languages
411
+ if detected in FUTURE_LANGUAGES:
412
+ console.print(
413
+ f"[yellow]Note:[/yellow] {detected} project detected. "
414
+ f"Using python templates (most similar). "
415
+ f"Native {detected} support coming soon."
416
+ )
417
+ language = "python"
418
+ else:
419
+ language = detected
420
+ else:
421
+ # Validate explicitly provided language
422
+ if language not in VALID_LANGUAGES:
423
+ valid = ", ".join(sorted(VALID_LANGUAGES))
424
+ console.print(f"[red]Error:[/red] Invalid language '{language}'. Must be one of: {valid}")
425
+ raise typer.Exit(1)
426
+
296
427
  # Header
297
428
  if claude:
298
429
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Claude Code)[/bold]")
@@ -301,7 +432,7 @@ def init(
301
432
  else:
302
433
  console.print(f"\n[bold]Invar v{__version__} - Project Setup[/bold]")
303
434
  console.print("=" * 45)
304
- console.print("[dim]Existing files will be MERGED (your content preserved).[/dim]")
435
+ console.print(f"[dim]Language: {language} | Existing files will be MERGED.[/dim]")
305
436
 
306
437
  # Determine agents and files
307
438
  if claude:
@@ -371,9 +502,10 @@ def init(
371
502
  if not selected_files.get(".pre-commit-config.yaml", True):
372
503
  skip_patterns.append(".pre-commit-config.yaml")
373
504
 
374
- # Run template sync
505
+ # Run template sync (LX-05: pass language for template rendering)
375
506
  sync_config = SyncConfig(
376
507
  syntax="cli",
508
+ language=language,
377
509
  inject_project_additions=(path / ".invar" / "project-additions.md").exists(),
378
510
  force=False,
379
511
  check=False,
@@ -397,8 +529,14 @@ def init(
397
529
 
398
530
  # Configure MCP if Claude selected
399
531
  if "claude" in agents and selected_files.get(".mcp.json", True):
400
- if _configure_mcp(path):
401
- 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}")
402
540
 
403
541
  # Create directories if selected
404
542
  if selected_files.get("src/core/", True):
@@ -36,44 +36,20 @@ def run_map(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
36
36
  Run the map command.
37
37
 
38
38
  Scans project and generates perception map with reference counts.
39
+ LX-06: Supports TypeScript projects (basic symbol listing).
39
40
  """
40
41
  if not path.exists():
41
42
  return Failure(f"Path does not exist: {path}")
42
43
 
43
- # Collect all files and their sources
44
- file_infos: list[FileInfo] = []
45
- sources: dict[str, str] = {}
44
+ # LX-06: Detect language and dispatch
45
+ from invar.shell.commands.init import detect_language
46
46
 
47
- for py_file in discover_python_files(path):
48
- try:
49
- content = py_file.read_text(encoding="utf-8")
50
- rel_path = str(py_file.relative_to(path))
51
- # Skip empty files (e.g., __init__.py)
52
- if not content.strip():
53
- continue
54
- file_info = parse_source(content, rel_path)
55
- if file_info:
56
- file_infos.append(file_info)
57
- sources[rel_path] = content
58
- except (OSError, UnicodeDecodeError) as e:
59
- console.print(f"[yellow]Warning:[/yellow] {py_file}: {e}")
60
- continue
61
-
62
- if not file_infos:
63
- return Failure("No Python files found")
64
-
65
- # Build perception map
66
- perception_map = build_perception_map(file_infos, sources, str(path.absolute()))
67
-
68
- # Output
69
- if json_output:
70
- output = format_map_json(perception_map, top_n)
71
- console.print(json.dumps(output, indent=2))
72
- else:
73
- output = format_map_text(perception_map, top_n)
74
- console.print(output)
47
+ project_language = detect_language(path)
48
+ if project_language == "typescript":
49
+ return _run_map_typescript(path, top_n, json_output)
75
50
 
76
- return Success(None)
51
+ # Python path (original logic)
52
+ return _run_map_python(path, top_n, json_output)
77
53
 
78
54
 
79
55
  # @shell_complexity: Signature extraction with symbol filtering
@@ -83,6 +59,7 @@ def run_sig(target: str, json_output: bool) -> Result[None, str]:
83
59
 
84
60
  Extracts signatures from a file or specific symbol.
85
61
  Target format: "path/to/file.py" or "path/to/file.py::symbol_name"
62
+ LX-06: Supports TypeScript files (.ts, .tsx).
86
63
  """
87
64
  # Parse target
88
65
  if "::" in target:
@@ -95,12 +72,26 @@ def run_sig(target: str, json_output: bool) -> Result[None, str]:
95
72
  if not file_path.exists():
96
73
  return Failure(f"File not found: {file_path}")
97
74
 
98
- # Read and parse
75
+ # Read file content
99
76
  try:
100
77
  content = file_path.read_text(encoding="utf-8")
101
78
  except (OSError, UnicodeDecodeError) as e:
102
79
  return Failure(f"Failed to read {file_path}: {e}")
103
80
 
81
+ # LX-06: Detect file type and dispatch to appropriate parser
82
+ suffix = file_path.suffix.lower()
83
+ if suffix in (".ts", ".tsx"):
84
+ return _run_sig_typescript(content, file_path, symbol_name, json_output)
85
+
86
+ # Python path (original logic)
87
+ return _run_sig_python(content, file_path, symbol_name, json_output)
88
+
89
+
90
+ # @shell_complexity: Python sig orchestration with error handling and output modes
91
+ def _run_sig_python(
92
+ content: str, file_path: Path, symbol_name: str | None, json_output: bool
93
+ ) -> Result[None, str]:
94
+ """Run sig for Python files."""
104
95
  # Handle empty files
105
96
  if not content.strip():
106
97
  file_info = FileInfo(path=str(file_path), lines=0, symbols=[], imports=[], source="")
@@ -125,3 +116,136 @@ def run_sig(target: str, json_output: bool) -> Result[None, str]:
125
116
  console.print(output)
126
117
 
127
118
  return Success(None)
119
+
120
+
121
+ # @shell_complexity: TypeScript sig orchestration with error handling and output modes
122
+ def _run_sig_typescript(
123
+ content: str, file_path: Path, symbol_name: str | None, json_output: bool
124
+ ) -> Result[None, str]:
125
+ """Run sig for TypeScript files (LX-06)."""
126
+ from invar.core.ts_sig_parser import (
127
+ extract_ts_signatures,
128
+ format_ts_signatures_json,
129
+ format_ts_signatures_text,
130
+ )
131
+
132
+ # Handle empty files consistently with Python path
133
+ symbols = [] if not content.strip() else extract_ts_signatures(content)
134
+
135
+ # Filter by symbol name if specified
136
+ if symbol_name:
137
+ symbols = [s for s in symbols if s.name == symbol_name]
138
+ if not symbols:
139
+ return Failure(f"Symbol '{symbol_name}' not found in {file_path}")
140
+
141
+ # Output
142
+ if json_output:
143
+ output = format_ts_signatures_json(symbols, str(file_path))
144
+ console.print(json.dumps(output, indent=2))
145
+ else:
146
+ output = format_ts_signatures_text(symbols, str(file_path))
147
+ console.print(output)
148
+
149
+ return Success(None)
150
+
151
+
152
+ # @shell_complexity: Python map with perception map building
153
+ def _run_map_python(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
154
+ """Run map for Python projects (original logic)."""
155
+ # Collect all files and their sources
156
+ file_infos: list[FileInfo] = []
157
+ sources: dict[str, str] = {}
158
+
159
+ for py_file in discover_python_files(path):
160
+ try:
161
+ content = py_file.read_text(encoding="utf-8")
162
+ rel_path = str(py_file.relative_to(path))
163
+ # Skip empty files (e.g., __init__.py)
164
+ if not content.strip():
165
+ continue
166
+ file_info = parse_source(content, rel_path)
167
+ if file_info:
168
+ file_infos.append(file_info)
169
+ sources[rel_path] = content
170
+ except (OSError, UnicodeDecodeError) as e:
171
+ console.print(f"[yellow]Warning:[/yellow] {py_file}: {e}")
172
+ continue
173
+
174
+ if not file_infos:
175
+ return Failure("No Python files found")
176
+
177
+ # Build perception map
178
+ perception_map = build_perception_map(file_infos, sources, str(path.absolute()))
179
+
180
+ # Output
181
+ if json_output:
182
+ output = format_map_json(perception_map, top_n)
183
+ console.print(json.dumps(output, indent=2))
184
+ else:
185
+ output = format_map_text(perception_map, top_n)
186
+ console.print(output)
187
+
188
+ return Success(None)
189
+
190
+
191
+ # @shell_complexity: TypeScript map with file discovery and symbol extraction
192
+ def _run_map_typescript(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
193
+ """Run map for TypeScript projects (LX-06).
194
+
195
+ MVP: Lists symbols without reference counting (Phase 2 can add references).
196
+ """
197
+ from invar.core.ts_sig_parser import TSSymbol, extract_ts_signatures
198
+ from invar.shell.fs import discover_typescript_files
199
+
200
+ all_symbols: list[tuple[str, TSSymbol]] = []
201
+
202
+ for ts_file in discover_typescript_files(path):
203
+ try:
204
+ content = ts_file.read_text(encoding="utf-8")
205
+ rel_path = str(ts_file.relative_to(path))
206
+ if not content.strip():
207
+ continue
208
+ symbols = extract_ts_signatures(content)
209
+ for sym in symbols:
210
+ all_symbols.append((rel_path, sym))
211
+ except (OSError, UnicodeDecodeError) as e:
212
+ console.print(f"[yellow]Warning:[/yellow] {ts_file}: {e}")
213
+ continue
214
+
215
+ if not all_symbols:
216
+ return Failure("No TypeScript symbols found (files may be empty or contain no exportable symbols)")
217
+
218
+ # Sort by kind priority (function/class first), then by name
219
+ kind_order = {"function": 0, "class": 1, "interface": 2, "type": 3, "const": 4, "method": 5}
220
+ all_symbols.sort(key=lambda x: (kind_order.get(x[1].kind, 99), x[1].name))
221
+
222
+ # Limit to top_n
223
+ display_symbols = all_symbols[:top_n] if top_n > 0 else all_symbols
224
+
225
+ # Output
226
+ if json_output:
227
+ output = {
228
+ "language": "typescript",
229
+ "total_symbols": len(all_symbols),
230
+ "symbols": [
231
+ {
232
+ "name": sym.name,
233
+ "kind": sym.kind,
234
+ "file": file_path,
235
+ "line": sym.line,
236
+ "signature": sym.signature,
237
+ }
238
+ for file_path, sym in display_symbols
239
+ ],
240
+ }
241
+ console.print(json.dumps(output, indent=2))
242
+ else:
243
+ console.print("[bold]TypeScript Symbol Map[/bold]")
244
+ console.print(f"Total symbols: {len(all_symbols)}\n")
245
+ for file_path, sym in display_symbols:
246
+ console.print(f"[{sym.kind}] {sym.name}")
247
+ console.print(f" {file_path}:{sym.line}")
248
+ console.print(f" {sym.signature}")
249
+ console.print()
250
+
251
+ return Success(None)
@@ -0,0 +1,187 @@
1
+ """
2
+ Skill management command for Invar.
3
+
4
+ LX-07: Extension Skills - CLI interface for skill management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from returns.result import Failure, Result
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from invar.shell.skill_manager import (
17
+ add_skill,
18
+ list_skills,
19
+ remove_skill,
20
+ update_skill,
21
+ )
22
+
23
+ PROJECT_SKILLS_DIR = ".claude/skills"
24
+
25
+ console = Console()
26
+
27
+ app = typer.Typer(help="Manage extension skills")
28
+
29
+
30
+ def _handle_result(result: Result[object, str]) -> None:
31
+ """Print error message if result is Failure."""
32
+ if isinstance(result, Failure):
33
+ console.print(f"[red]Error:[/red] {result.failure()}")
34
+ raise typer.Exit(1)
35
+
36
+
37
+ # @invar:allow entry_point_too_thick: Typer callback with docstring examples
38
+ @app.callback(invoke_without_command=True)
39
+ def skill_callback(ctx: typer.Context) -> None:
40
+ """
41
+ Manage Invar extension skills.
42
+
43
+ Extension skills add specialized capabilities like acceptance testing
44
+ and security auditing to your project.
45
+
46
+ \b
47
+ Examples:
48
+ invar skill # List all skills
49
+ invar skill add security # Install security skill
50
+ invar skill remove security
51
+ invar skill update security
52
+ """
53
+ # If no subcommand, show list
54
+ if ctx.invoked_subcommand is None:
55
+ list_cmd(Path())
56
+
57
+
58
+ # @invar:allow entry_point_too_thick: Rich table formatting for CLI output
59
+ @app.command("list")
60
+ def list_cmd(
61
+ path: Path = typer.Argument(Path(), help="Project root directory"),
62
+ ) -> None:
63
+ """List available extension skills."""
64
+ path = path.resolve()
65
+
66
+ result = list_skills(path, console)
67
+ if isinstance(result, Failure):
68
+ _handle_result(result)
69
+ return
70
+
71
+ skills = result.unwrap()
72
+
73
+ # Create rich table
74
+ table = Table(title="Extension Skills", show_header=True)
75
+ table.add_column("Name", style="cyan")
76
+ table.add_column("Tier", style="dim")
77
+ table.add_column("Status", style="green")
78
+ table.add_column("Description")
79
+
80
+ # Status styling
81
+ status_styles = {
82
+ "installed": "[green]installed[/green]",
83
+ "available": "[blue]available[/blue]",
84
+ "pending_discussion": "[yellow]pending[/yellow]",
85
+ }
86
+
87
+ for skill in skills:
88
+ status_display = status_styles.get(skill.status, skill.status)
89
+ isolation = " [dim](isolated)[/dim]" if skill.isolation else ""
90
+
91
+ table.add_row(
92
+ f"/{skill.name}",
93
+ skill.tier,
94
+ status_display,
95
+ f"{skill.description}{isolation}",
96
+ )
97
+
98
+ console.print(table)
99
+ console.print()
100
+ console.print("[dim]Use 'invar skill add <name>' to install a skill[/dim]")
101
+
102
+
103
+ # @invar:allow entry_point_too_thick: CLI output with usage hints
104
+ @app.command("add")
105
+ def add_cmd(
106
+ name: str = typer.Argument(..., help="Skill name to add"),
107
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
108
+ ) -> None:
109
+ """Add or update an extension skill (idempotent)."""
110
+ path = path.resolve()
111
+
112
+ # DX-71: add_skill now prints its own status (Adding/Updating)
113
+ result = add_skill(name, path, console)
114
+
115
+ if isinstance(result, Failure):
116
+ _handle_result(result)
117
+ return
118
+
119
+ console.print(f"[green]{result.unwrap()}[/green]")
120
+ console.print()
121
+ console.print(f"[dim]Use '/{name}' in Claude Code to invoke the skill[/dim]")
122
+
123
+
124
+ # @invar:allow entry_point_too_thick: CLI with confirmation dialog
125
+ @app.command("remove")
126
+ def remove_cmd(
127
+ name: str = typer.Argument(..., help="Skill name to remove"),
128
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
129
+ force: bool = typer.Option(False, "--force", "-f", help="Force removal"),
130
+ ) -> None:
131
+ """Remove an extension skill from the project."""
132
+ from invar.shell.skill_manager import has_user_extensions
133
+
134
+ path = path.resolve()
135
+ skill_dir = path / PROJECT_SKILLS_DIR / name
136
+
137
+ # DX-71 review: Check existence before any user interaction
138
+ if not skill_dir.exists():
139
+ console.print(f"[red]Error:[/red] Skill not installed: {name}")
140
+ raise typer.Exit(1)
141
+
142
+ # DX-71: Check extensions FIRST to avoid confusing confirmation→failure flow
143
+ if not force:
144
+ # If skill has user extensions, require --force (no confirmation dialog)
145
+ if has_user_extensions(skill_dir):
146
+ console.print(
147
+ f"[yellow]Warning:[/yellow] Skill '{name}' has custom extensions "
148
+ "content that will be lost."
149
+ )
150
+ console.print(
151
+ "[dim]Use --force to confirm removal, or backup extensions first.[/dim]"
152
+ )
153
+ raise typer.Exit(1)
154
+
155
+ # No extensions - show simple confirmation dialog
156
+ confirm = typer.confirm(f"Remove skill '{name}'?")
157
+ if not confirm:
158
+ console.print("[yellow]Cancelled[/yellow]")
159
+ raise typer.Exit(0)
160
+
161
+ console.print(f"[bold]Removing skill:[/bold] {name}")
162
+ # force=True here because we've already done CLI-level checks
163
+ result = remove_skill(name, path, console, force=True)
164
+
165
+ if isinstance(result, Failure):
166
+ _handle_result(result)
167
+ return
168
+
169
+ console.print(f"[green]{result.unwrap()}[/green]")
170
+
171
+
172
+ @app.command("update")
173
+ def update_cmd(
174
+ name: str = typer.Argument(..., help="Skill name to update"),
175
+ path: Path = typer.Option(Path(), "--path", "-p", help="Project root"),
176
+ ) -> None:
177
+ """Update an extension skill (deprecated, use 'add' instead)."""
178
+ path = path.resolve()
179
+
180
+ # DX-71: update_skill now shows deprecation notice and delegates to add_skill
181
+ result = update_skill(name, path, console)
182
+
183
+ if isinstance(result, Failure):
184
+ _handle_result(result)
185
+ return
186
+
187
+ console.print(f"[green]{result.unwrap()}[/green]")