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.
- invar/__init__.py +8 -0
- invar/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +310 -0
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +41 -1
- invar/shell/commands/init.py +154 -16
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +60 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +67 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +902 -0
- invar/shell/skill_manager.py +355 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +58 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +9 -0
- invar/templates/manifest.toml +7 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +85 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/review/SKILL.md.jinja +220 -248
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
- invar_tools-1.11.0.dist-info/RECORD +178 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.8.0.dist-info/RECORD +0 -116
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
invar/shell/commands/init.py
CHANGED
|
@@ -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
|
|
223
|
-
return False
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
272
|
-
- --pi
|
|
343
|
+
- --claude Auto-select Claude Code (MCP + hooks + skills)
|
|
344
|
+
- --pi Auto-select Pi (shares CLAUDE.md + skills, adds Pi hooks)
|
|
345
|
+
- --mcp-only Install MCP tools only (minimal, no framework files)
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
#
|
|
44
|
-
|
|
45
|
-
sources: dict[str, str] = {}
|
|
44
|
+
# LX-06: Detect language and dispatch
|
|
45
|
+
from invar.shell.commands.init import detect_language
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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]")
|