invar-tools 1.7.1__py3-none-any.whl → 1.10.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 (113) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/language.py +88 -0
  3. invar/core/models.py +106 -0
  4. invar/core/patterns/detector.py +6 -1
  5. invar/core/patterns/p0_exhaustive.py +15 -3
  6. invar/core/patterns/p0_literal.py +15 -3
  7. invar/core/patterns/p0_newtype.py +15 -3
  8. invar/core/patterns/p0_nonempty.py +15 -3
  9. invar/core/patterns/p0_validation.py +15 -3
  10. invar/core/patterns/registry.py +5 -1
  11. invar/core/patterns/types.py +5 -1
  12. invar/core/property_gen.py +4 -0
  13. invar/core/rules.py +84 -18
  14. invar/core/sync_helpers.py +27 -1
  15. invar/core/template_helpers.py +32 -0
  16. invar/core/ts_parsers.py +286 -0
  17. invar/core/ts_sig_parser.py +307 -0
  18. invar/node_tools/MANIFEST +7 -0
  19. invar/node_tools/__init__.py +51 -0
  20. invar/node_tools/fc-runner/cli.js +77 -0
  21. invar/node_tools/quick-check/cli.js +28 -0
  22. invar/node_tools/ts-analyzer/cli.js +480 -0
  23. invar/shell/claude_hooks.py +35 -12
  24. invar/shell/commands/guard.py +36 -1
  25. invar/shell/commands/init.py +133 -7
  26. invar/shell/commands/perception.py +157 -33
  27. invar/shell/commands/skill.py +187 -0
  28. invar/shell/commands/template_sync.py +65 -13
  29. invar/shell/commands/uninstall.py +77 -12
  30. invar/shell/commands/update.py +6 -14
  31. invar/shell/contract_coverage.py +1 -0
  32. invar/shell/fs.py +66 -13
  33. invar/shell/pi_hooks.py +213 -0
  34. invar/shell/prove/guard_ts.py +899 -0
  35. invar/shell/skill_manager.py +353 -0
  36. invar/shell/template_engine.py +28 -4
  37. invar/shell/templates.py +4 -4
  38. invar/templates/claude-md/python/critical-rules.md +33 -0
  39. invar/templates/claude-md/python/quick-reference.md +24 -0
  40. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  41. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  42. invar/templates/claude-md/universal/check-in.md +25 -0
  43. invar/templates/claude-md/universal/skills.md +73 -0
  44. invar/templates/claude-md/universal/workflow.md +55 -0
  45. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  46. invar/templates/config/AGENT.md.jinja +256 -0
  47. invar/templates/config/CLAUDE.md.jinja +16 -209
  48. invar/templates/config/context.md.jinja +19 -0
  49. invar/templates/examples/{README.md → python/README.md} +2 -0
  50. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  51. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  52. invar/templates/examples/python/core_shell.py +227 -0
  53. invar/templates/examples/python/functional.py +613 -0
  54. invar/templates/examples/typescript/README.md +31 -0
  55. invar/templates/examples/typescript/contracts.ts +163 -0
  56. invar/templates/examples/typescript/core_shell.ts +374 -0
  57. invar/templates/examples/typescript/functional.ts +601 -0
  58. invar/templates/examples/typescript/workflow.md +95 -0
  59. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  60. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  61. invar/templates/hooks/Stop.sh.jinja +1 -1
  62. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  63. invar/templates/hooks/pi/invar.ts.jinja +82 -0
  64. invar/templates/manifest.toml +8 -6
  65. invar/templates/onboard/assessment.md.jinja +214 -0
  66. invar/templates/onboard/patterns/python.md +347 -0
  67. invar/templates/onboard/patterns/typescript.md +452 -0
  68. invar/templates/onboard/roadmap.md.jinja +168 -0
  69. invar/templates/protocol/INVAR.md.jinja +51 -0
  70. invar/templates/protocol/python/architecture-examples.md +41 -0
  71. invar/templates/protocol/python/contracts-syntax.md +56 -0
  72. invar/templates/protocol/python/markers.md +44 -0
  73. invar/templates/protocol/python/tools.md +24 -0
  74. invar/templates/protocol/python/troubleshooting.md +38 -0
  75. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  76. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  77. invar/templates/protocol/typescript/markers.md +48 -0
  78. invar/templates/protocol/typescript/tools.md +65 -0
  79. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  80. invar/templates/protocol/universal/architecture.md +36 -0
  81. invar/templates/protocol/universal/completion.md +14 -0
  82. invar/templates/protocol/universal/contracts-concept.md +37 -0
  83. invar/templates/protocol/universal/header.md +17 -0
  84. invar/templates/protocol/universal/session.md +17 -0
  85. invar/templates/protocol/universal/six-laws.md +10 -0
  86. invar/templates/protocol/universal/usbv.md +14 -0
  87. invar/templates/protocol/universal/visible-workflow.md +25 -0
  88. invar/templates/skills/develop/SKILL.md.jinja +98 -3
  89. invar/templates/skills/extensions/_registry.yaml +93 -0
  90. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  91. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  92. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  93. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  94. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  95. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  96. invar/templates/skills/extensions/security/SKILL.md +382 -0
  97. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  98. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  99. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  100. invar/templates/skills/investigate/SKILL.md.jinja +15 -0
  101. invar/templates/skills/propose/SKILL.md.jinja +33 -0
  102. invar/templates/skills/review/SKILL.md.jinja +346 -71
  103. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/METADATA +326 -19
  104. invar_tools-1.10.0.dist-info/RECORD +173 -0
  105. invar/templates/examples/core_shell.py +0 -127
  106. invar/templates/protocol/INVAR.md +0 -310
  107. invar_tools-1.7.1.dist-info/RECORD +0 -112
  108. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  109. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
  110. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
  111. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
  112. {invar_tools-1.7.1.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
  113. {invar_tools-1.7.1.dist-info → invar_tools-1.10.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:
@@ -158,6 +158,8 @@ def guard(
158
158
  Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
159
159
  Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
160
160
  """
161
+ # LX-06: Language detection and dispatch
162
+ from invar.shell.commands.init import detect_language
161
163
  from invar.shell.guard_helpers import (
162
164
  collect_files_to_check,
163
165
  handle_changed_mode,
@@ -168,7 +170,38 @@ def guard(
168
170
  )
169
171
  from invar.shell.testing import VerificationLevel
170
172
 
171
- # DX-65: Handle single file mode
173
+ project_language = detect_language(path if path.is_dir() else find_project_root(path))
174
+
175
+ # Dispatch to language-specific guard if not Python
176
+ if project_language == "typescript":
177
+ from invar.shell.prove.guard_ts import run_typescript_guard
178
+
179
+ ts_result = run_typescript_guard(path if path.is_dir() else find_project_root(path))
180
+ match ts_result:
181
+ case Success(result):
182
+ if json_output or agent:
183
+ import json as json_mod
184
+
185
+ from invar.shell.prove.guard_ts import format_typescript_guard_v2
186
+
187
+ output = format_typescript_guard_v2(result)
188
+ console.print(json_mod.dumps(output, indent=2))
189
+ else:
190
+ console.print(f"[bold]TypeScript Guard[/bold] ({project_language})")
191
+ if result.status == "passed":
192
+ console.print("[green]✓ PASSED[/green]")
193
+ elif result.status == "skipped":
194
+ console.print("[yellow]⚠ SKIPPED[/yellow] (no TypeScript tools available)")
195
+ else:
196
+ console.print(f"[red]✗ FAILED[/red] ({result.error_count} errors)")
197
+ for v in result.violations[:10]: # Show first 10
198
+ console.print(f" {v.file}:{v.line}: [{v.severity}] {v.message}")
199
+ raise typer.Exit(0 if result.status == "passed" else 1)
200
+ case Failure(err):
201
+ console.print(f"[red]Error:[/red] {err}")
202
+ raise typer.Exit(1)
203
+
204
+ # DX-65: Handle single file mode (Python only from here)
172
205
  single_file_mode = path.is_file()
173
206
  single_file: Path | None = None
174
207
  if single_file_mode:
@@ -519,6 +552,7 @@ def rules(
519
552
  from invar.shell.commands.hooks import app as hooks_app # DX-57
520
553
  from invar.shell.commands.init import init
521
554
  from invar.shell.commands.mutate import mutate # DX-28
555
+ from invar.shell.commands.skill import app as skill_app # LX-07
522
556
  from invar.shell.commands.sync_self import sync_self # DX-49
523
557
  from invar.shell.commands.test import test, verify
524
558
  from invar.shell.commands.uninstall import uninstall # DX-69
@@ -531,6 +565,7 @@ app.command()(test)
531
565
  app.command()(verify)
532
566
  app.command()(mutate) # DX-28: Mutation testing
533
567
  app.add_typer(hooks_app, name="hooks") # DX-57: Claude Code hooks management
568
+ app.add_typer(skill_app, name="skill") # LX-07: Extension skills management
534
569
 
535
570
  # DX-56: Create dev subcommand group for developer commands
536
571
  dev_app = typer.Typer(
@@ -15,13 +15,14 @@ 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 (
22
22
  generate_mcp_json,
23
23
  get_recommended_method,
24
24
  )
25
+ from invar.shell.pi_hooks import install_pi_hooks
25
26
  from invar.shell.templates import (
26
27
  add_config,
27
28
  create_directories,
@@ -55,15 +56,72 @@ FILE_CATEGORIES: dict[str, list[tuple[str, str]]] = {
55
56
  "generic": [
56
57
  ("AGENT.md", "Universal agent instructions"),
57
58
  ],
59
+ "pi": [
60
+ ("CLAUDE.md", "Agent instructions (Pi compatible)"),
61
+ (".claude/skills/", "Workflow automation (Pi compatible)"),
62
+ (".pi/hooks/", "Pi-specific hooks"),
63
+ ],
58
64
  }
59
65
 
60
66
  AGENT_CONFIGS: dict[str, dict[str, str]] = {
61
67
  "claude": {"name": "Claude Code", "category": "claude"},
68
+ "pi": {"name": "Pi Coding Agent", "category": "pi"},
62
69
  "generic": {"name": "Other (AGENT.md)", "category": "generic"},
63
- # Future: "cursor", "windsurf", etc.
64
70
  }
65
71
 
66
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
+
67
125
  # =============================================================================
68
126
  # Interactive Prompts (DX-70)
69
127
  # =============================================================================
@@ -103,6 +161,7 @@ def _prompt_agent_selection() -> list[str]:
103
161
 
104
162
  choices = [
105
163
  questionary.Choice("Claude Code (recommended)", value="claude"),
164
+ questionary.Choice("Pi Coding Agent", value="pi"),
106
165
  questionary.Choice("Other (AGENT.md)", value="generic"),
107
166
  ]
108
167
 
@@ -156,6 +215,8 @@ def _prompt_file_selection(agents: list[str]) -> dict[str, bool]:
156
215
  category_name = category.capitalize()
157
216
  if category == "claude":
158
217
  category_name = "Claude Code"
218
+ elif category == "pi":
219
+ category_name = "Pi Coding Agent"
159
220
  choices.append(questionary.Separator(f"── {category_name} ──"))
160
221
  for file, desc in files:
161
222
  choices.append(
@@ -241,6 +302,17 @@ def init(
241
302
  "--claude",
242
303
  help="Auto-select Claude Code, skip all prompts",
243
304
  ),
305
+ pi: bool = typer.Option(
306
+ False,
307
+ "--pi",
308
+ help="Auto-select Pi Coding Agent, skip all prompts",
309
+ ),
310
+ language: str | None = typer.Option(
311
+ None,
312
+ "--language",
313
+ "-l",
314
+ help="Target language (auto-detected if not specified): python, typescript",
315
+ ),
244
316
  preview: bool = typer.Option(
245
317
  False,
246
318
  "--preview",
@@ -252,6 +324,11 @@ def init(
252
324
 
253
325
  DX-70: Simplified init with interactive selection and safe merge.
254
326
 
327
+ \b
328
+ Quick setup options:
329
+ - --claude Auto-select Claude Code (MCP + hooks + skills)
330
+ - --pi Auto-select Pi (shares CLAUDE.md + skills, adds Pi hooks)
331
+
255
332
  \b
256
333
  This command is safe - it always MERGES with existing files:
257
334
  - File doesn't exist → Create
@@ -264,31 +341,65 @@ def init(
264
341
  """
265
342
  from invar import __version__
266
343
 
344
+ # Mutual exclusivity check
345
+ if claude and pi:
346
+ console.print("[red]Error:[/red] Cannot use --claude and --pi together.")
347
+ raise typer.Exit(1)
348
+
267
349
  # Resolve path
268
350
  if path == Path():
269
351
  path = Path.cwd()
270
352
  path = path.resolve()
271
353
 
354
+ # LX-05: Language detection and validation
355
+ if language is None:
356
+ detected = detect_language(path)
357
+ # Fall back to python for unsupported detected languages
358
+ if detected in FUTURE_LANGUAGES:
359
+ console.print(
360
+ f"[yellow]Note:[/yellow] {detected} project detected. "
361
+ f"Using python templates (most similar). "
362
+ f"Native {detected} support coming soon."
363
+ )
364
+ language = "python"
365
+ else:
366
+ language = detected
367
+ else:
368
+ # Validate explicitly provided language
369
+ if language not in VALID_LANGUAGES:
370
+ valid = ", ".join(sorted(VALID_LANGUAGES))
371
+ console.print(f"[red]Error:[/red] Invalid language '{language}'. Must be one of: {valid}")
372
+ raise typer.Exit(1)
373
+
272
374
  # Header
273
375
  if claude:
274
376
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Claude Code)[/bold]")
377
+ elif pi:
378
+ console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Pi)[/bold]")
275
379
  else:
276
380
  console.print(f"\n[bold]Invar v{__version__} - Project Setup[/bold]")
277
381
  console.print("=" * 45)
278
- console.print("[dim]Existing files will be MERGED (your content preserved).[/dim]")
382
+ console.print(f"[dim]Language: {language} | Existing files will be MERGED.[/dim]")
279
383
 
280
384
  # Determine agents and files
281
385
  if claude:
282
- # Quick mode: use defaults
386
+ # Quick mode: Claude Code defaults
283
387
  agents = ["claude"]
284
388
  selected_files: dict[str, bool] = {}
285
389
  for category in ["optional", "claude"]:
286
390
  for file, _ in FILE_CATEGORIES.get(category, []):
287
391
  selected_files[file] = True
392
+ elif pi:
393
+ # Quick mode: Pi defaults
394
+ agents = ["pi"]
395
+ selected_files = {}
396
+ for category in ["optional", "pi"]:
397
+ for file, _ in FILE_CATEGORIES.get(category, []):
398
+ selected_files[file] = True
288
399
  else:
289
400
  # Interactive mode
290
401
  if not _is_interactive():
291
- console.print("[yellow]Non-interactive terminal detected. Use --claude for quick setup.[/yellow]")
402
+ console.print("[yellow]Non-interactive terminal detected. Use --claude or --pi for quick setup.[/yellow]")
292
403
  raise typer.Exit(1)
293
404
 
294
405
  agents = _prompt_agent_selection()
@@ -338,9 +449,10 @@ def init(
338
449
  if not selected_files.get(".pre-commit-config.yaml", True):
339
450
  skip_patterns.append(".pre-commit-config.yaml")
340
451
 
341
- # Run template sync
452
+ # Run template sync (LX-05: pass language for template rendering)
342
453
  sync_config = SyncConfig(
343
454
  syntax="cli",
455
+ language=language,
344
456
  inject_project_additions=(path / ".invar" / "project-additions.md").exists(),
345
457
  force=False,
346
458
  check=False,
@@ -379,6 +491,10 @@ def init(
379
491
  if "claude" in agents and selected_files.get(".claude/hooks/", True):
380
492
  install_claude_hooks(path, console)
381
493
 
494
+ # Install Pi hooks if selected
495
+ if "pi" in agents and selected_files.get(".pi/hooks/", True):
496
+ install_pi_hooks(path, console)
497
+
382
498
  # Create MCP setup guide
383
499
  mcp_setup = invar_dir / "mcp-setup.md"
384
500
  if not mcp_setup.exists():
@@ -397,7 +513,7 @@ def init(
397
513
  # Completion message
398
514
  console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
399
515
 
400
- # Show tip for Claude users
516
+ # Show agent-specific tips
401
517
  if "claude" in agents:
402
518
  console.print()
403
519
  console.print(
@@ -408,3 +524,13 @@ def init(
408
524
  border_style="dim",
409
525
  )
410
526
  )
527
+ elif "pi" in agents:
528
+ console.print()
529
+ console.print(
530
+ Panel(
531
+ "[dim]Pi reads CLAUDE.md and .claude/skills/ directly.\n"
532
+ "Run [bold]pi[/bold] to start — USBV workflow is auto-enabled.[/dim]",
533
+ title="📌 Tip",
534
+ border_style="dim",
535
+ )
536
+ )
@@ -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)