File size: 15,170 Bytes
d0808a4
 
 
 
7389066
 
 
 
 
b6b64c4
7389066
 
 
 
 
 
 
 
 
 
 
 
b6b64c4
7389066
 
 
b6b64c4
7389066
 
b6b64c4
7389066
 
 
 
 
 
b6b64c4
7389066
 
 
 
 
 
 
 
b6b64c4
7389066
 
b6b64c4
bace889
 
 
b6b64c4
bace889
 
b6b64c4
7389066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6b64c4
7389066
b6b64c4
7389066
 
 
 
 
bace889
7389066
b6b64c4
7389066
b6b64c4
7389066
 
 
 
 
b6b64c4
7389066
b6b64c4
7389066
 
 
 
b6b64c4
7389066
 
 
 
 
b6b64c4
7389066
b6b64c4
7389066
 
 
 
 
b6b64c4
7389066
 
d0808a4
 
 
 
 
b6b64c4
d0808a4
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
 
b6b64c4
d0808a4
b6b64c4
 
 
 
 
d0808a4
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
 
b6b64c4
d0808a4
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
 
 
 
 
 
 
 
 
 
 
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
b6b64c4
 
 
 
 
 
d0808a4
 
b6b64c4
 
 
d0808a4
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
 
 
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
b6b64c4
 
 
 
 
d0808a4
 
b6b64c4
 
 
 
d0808a4
 
 
 
 
 
 
b6b64c4
d0808a4
 
 
b6b64c4
d0808a4
 
 
 
 
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
b6b64c4
 
 
 
d0808a4
b6b64c4
d0808a4
 
 
 
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
 
 
 
 
 
b6b64c4
d0808a4
 
 
 
 
 
 
b6b64c4
d0808a4
 
b6b64c4
d0808a4
7389066
d0808a4
7389066
d0808a4
b6b64c4
d0808a4
7389066
d0808a4
7389066
 
d0808a4
 
 
 
 
 
 
 
 
 
 
 
 
 
7389066
 
 
 
 
 
 
 
d0808a4
b6b64c4
d0808a4
 
b6b64c4
d0808a4
 
 
 
 
 
b6b64c4
d0808a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6b64c4
d0808a4
b6b64c4
 
 
 
d0808a4
 
b6b64c4
d0808a4
 
 
 
 
b6b64c4
d0808a4
 
 
b6b64c4
 
 
d0808a4
 
 
 
 
b6b64c4
 
 
d0808a4
 
 
 
 
b6b64c4
d0808a4
b6b64c4
d0808a4
b6b64c4
d0808a4
b6b64c4
5cb7289
 
 
 
305bf67
 
 
 
b6b64c4
305bf67
5cb7289
 
b6b64c4
5cb7289
b6b64c4
5cb7289
b6b64c4
d0808a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6b64c4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
import gradio as gr
import subprocess
import json
import os
from typing import Dict, Any, List, Optional
import requests
from functools import lru_cache
import time


# Cache documentation for 1 hour to avoid excessive requests
@lru_cache(maxsize=10)
def fetch_liftoscript_docs_cached(url: str, cache_time: float) -> Optional[str]:
    """Fetch documentation from Liftosaur website with caching."""
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        return response.text
    except Exception as e:
        print(f"Error fetching docs from {url}: {e}")
        return None


def get_liftoscript_documentation(topic: str = "overview") -> Dict[str, Any]:
    """
    Access official Liftoscript documentation from liftosaur.com.

    This tool fetches the latest documentation directly from the official
    Liftosaur website to ensure accuracy and up-to-date information.

    Args:
        topic: Documentation section to retrieve. Options:
               - overview: General introduction to Liftoscript
               - syntax: Exercise notation and formatting rules
               - progressions: Built-in progression types
               - all: Complete documentation

    Returns:
        Dictionary containing:
        - topic: The requested topic name
        - content: Documentation text from official source
        - source: URL where documentation was fetched from
        - cached: Whether this is cached data
        - error: True if there was an error fetching docs
    """

    # Use current time for cache invalidation (1 hour cache)
    cache_time = time.time() // 3600

    # Use Jina Reader for clean markdown conversion
    base_url = "https://www.liftosaur.com/blog/docs/"
    jina_url = f"https://r.jina.ai/{base_url}"

    # Fetch documentation via Jina Reader
    main_docs = fetch_liftoscript_docs_cached(jina_url, cache_time)

    if not main_docs:
        # Fallback to basic documentation if fetch fails
        return {
            "topic": topic,
            "content": """# Liftoscript Quick Reference (Offline)

## Basic Format
Exercise Name / Sets x Reps / Weight / progress: type

## Progression Types
- lp(weight): Linear progression
- dp(weight, minReps, maxReps): Double progression  
- sum(totalReps, weight): Sum of reps progression
- custom() { ... }: Custom JavaScript-like logic

## Weight Notation
- Absolute: 135lb, 60kg
- Percentage: 70%, 85%
- RPE: 3x5 @8 (3 sets of 5 at RPE 8)

Note: Full documentation available at https://www.liftosaur.com/docs""",
            "source": "offline_fallback",
            "cached": False,
            "error": True,
        }

    # Parse specific sections based on topic
    if topic == "all":
        return {
            "topic": "all",
            "content": main_docs,
            "source": base_url,  # Show original URL, not Jina URL
            "cached": True,
            "error": False,
        }

    # Extract specific sections from the documentation
    # This is a simplified extraction - in production you'd parse the HTML/markdown properly
    sections = {
        "overview": "Liftoscript is a programming language for defining workout programs",
        "syntax": "Exercise format, sets/reps notation, weight formats",
        "progressions": "Linear (lp), Double (dp), Sum, and Custom progressions",
    }

    if topic in sections:
        # Try to extract the specific section from main_docs
        # In a real implementation, you'd parse the HTML/markdown structure
        content = f"# {topic.title()}\n\n{sections[topic]}\n\nFor complete documentation, see: {base_url}"

        return {
            "topic": topic,
            "content": content,
            "source": base_url,
            "cached": True,
            "error": False,
        }

    return {
        "topic": topic,
        "content": f"Unknown topic '{topic}'. Available topics: overview, syntax, progressions, all",
        "source": base_url,
        "cached": False,
        "error": True,
    }


def setup_parser():
    """Setup the minimal parser - just needs npm install."""
    try:
        # Check if Node.js is available
        result = subprocess.run(["node", "--version"], capture_output=True, text=True)
        if result.returncode != 0:
            return False, "Node.js not found. Please ensure Node.js is installed."

        # Check if dependencies are installed
        if not os.path.exists("node_modules/@lezer/lr"):
            print("Installing dependencies...")
            install_result = subprocess.run(
                ["npm", "install"], capture_output=True, text=True, timeout=60
            )
            if install_result.returncode != 0:
                return False, f"Failed to install dependencies: {install_result.stderr}"

        # Check if parser files exist
        required_files = [
            "minimal-validator.js",
            "liftoscript-parser.js",
            "planner-parser.js",
        ]
        missing_files = [f for f in required_files if not os.path.exists(f)]

        if missing_files:
            return False, f"Missing required files: {', '.join(missing_files)}"

        # Test the validator
        test_result = subprocess.run(
            ["node", "minimal-validator.js", "state.weight = 100lb", "--json"],
            capture_output=True,
            text=True,
            timeout=5,
        )

        if test_result.returncode != 0:
            return False, "Validator test failed"

        return True, "Parser ready (minimal Lezer-based validator)"

    except Exception as e:
        return False, f"Setup error: {str(e)}"


def validate_liftoscript(script: str) -> Dict[str, Any]:
    """
    Validate a Liftoscript program using the minimal Lezer-based parser.

    This tool validates Liftoscript workout programs and returns detailed error information
    if the syntax is invalid. It supports both planner format (Week/Day/Exercise) and
    pure Liftoscript expressions.

    Args:
        script: The Liftoscript code to validate (either planner format or expression)

    Returns:
        Validation result dictionary with keys:
        - valid (bool): Whether the script is syntactically valid
        - error (str|None): Error message if invalid
        - line (int|None): Line number of error
        - column (int|None): Column number of error
        - type (str|None): Detected script type ('planner' or 'liftoscript')
    """
    if not script or not script.strip():
        return {
            "valid": False,
            "error": "Empty script provided",
            "line": None,
            "column": None,
            "type": None,
        }

    try:
        # Use the minimal parser wrapper
        wrapper = (
            "parser-wrapper-minimal.js"
            if os.path.exists("parser-wrapper-minimal.js")
            else "minimal-validator.js"
        )

        # Call the validator
        result = subprocess.run(
            ["node", wrapper, "-"]
            if wrapper == "parser-wrapper-minimal.js"
            else ["node", wrapper, "-", "--json"],
            input=script,
            capture_output=True,
            text=True,
            timeout=10,
        )

        if result.returncode != 0 and not result.stdout:
            return {
                "valid": False,
                "error": f"Validator error: {result.stderr}",
                "line": None,
                "column": None,
                "type": None,
            }

        # Parse JSON response
        try:
            validation_result = json.loads(result.stdout)

            # Handle both formats (wrapper and direct)
            if "errors" in validation_result:
                # Wrapper format
                if (
                    validation_result.get("errors")
                    and len(validation_result["errors"]) > 0
                ):
                    first_error = validation_result["errors"][0]
                    return {
                        "valid": False,
                        "error": first_error.get("message", "Unknown error"),
                        "line": first_error.get("line"),
                        "column": first_error.get("column"),
                        "type": validation_result.get("type"),
                    }
                else:
                    return {
                        "valid": True,
                        "error": None,
                        "line": None,
                        "column": None,
                        "type": validation_result.get("type", "unknown"),
                    }
            else:
                # Direct format
                if validation_result.get("valid"):
                    return {
                        "valid": True,
                        "error": None,
                        "line": None,
                        "column": None,
                        "type": validation_result.get("type", "unknown"),
                    }
                else:
                    error = validation_result.get("error", {})
                    return {
                        "valid": False,
                        "error": error.get("message", "Unknown error"),
                        "line": error.get("line"),
                        "column": error.get("column"),
                        "type": error.get("type"),
                    }

        except json.JSONDecodeError:
            return {
                "valid": False,
                "error": "Invalid parser response",
                "line": None,
                "column": None,
                "type": None,
            }

    except subprocess.TimeoutExpired:
        return {
            "valid": False,
            "error": "Parser timeout - script too complex",
            "line": None,
            "column": None,
            "type": None,
        }
    except Exception as e:
        return {
            "valid": False,
            "error": f"Validation error: {str(e)}",
            "line": None,
            "column": None,
            "type": None,
        }


# Create Gradio interface
with gr.Blocks(title="Liftoscript MCP Server") as app:
    gr.Markdown("""
    # πŸ’ͺ Liftoscript MCP Server
    
    This MCP server provides tools for working with [Liftoscript](https://www.liftosaur.com/blog/docs/), the workout programming language used in Liftosaur.
    
    ## πŸ› οΈ Available MCP Tools
    
    1. **validate_liftoscript** - Validate Liftoscript syntax and get detailed error information
    2. **get_liftoscript_documentation** - Access official documentation for learning Liftoscript
    
    ## πŸ“¦ MCP Server Configuration
    
    Add to your `claude_desktop_config.json`:
    ```json
    {
      "mcpServers": {
        "liftoscript": {
          "command": "npx",
          "args": ["mcp-remote", "https://davanstrien-liftosaur-mcp.hf.space/gradio_api/mcp/sse"]
        }
      }
    }
    ```
    
    ## 🎯 Use Cases
    
    - **For LLMs**: Access documentation and validate generated Liftoscript programs
    - **For Developers**: Validate Liftoscript syntax in your applications
    - **For Users**: Test and validate your Liftoscript programs
    
    *Note: The documentation tool is available via MCP but not shown in this UI to keep it simple.*
    """)

    # Check parser setup
    parser_ready, setup_message = setup_parser()

    with gr.Row():
        with gr.Column():
            if parser_ready:
                gr.Markdown(f"**βœ… Parser Status**: {setup_message}")
            else:
                gr.Markdown(f"**⚠️ Parser Status**: {setup_message}")

    with gr.Row():
        with gr.Column():
            script_input = gr.Textbox(
                label="Liftoscript Code",
                placeholder="""Enter either Planner format:

# Week 1
## Day 1
Squat / 3x5 / progress: lp(5lb)
Bench Press / 3x8 @8

Or pure Liftoscript expressions:

if (completedReps >= reps) {
  state.weight = state.weight + 5lb
}""",
                lines=15,
            )
            validate_btn = gr.Button(
                "Validate", variant="primary", interactive=parser_ready
            )

        with gr.Column():
            validation_output = gr.JSON(label="Validation Result")

            gr.Markdown("""
            ### πŸ“ Example Scripts
            
            Click any example below to test:
            """)

            gr.Examples(
                examples=[
                    # Planner examples
                    [
                        "# Week 1\n## Day 1\nSquat / 3x5 / progress: lp(5lb)\nBench Press / 3x8"
                    ],
                    ["Deadlift / 1x5 / warmup: 1x5 135lb, 1x3 225lb, 1x1 315lb"],
                    ["Bench Press / 3x8-12 @8 / progress: dp"],
                    # Liftoscript examples
                    ["state.weight = 100lb"],
                    ["if (completedReps >= reps) { state.weight += 5lb }"],
                    [
                        "for (r in completedReps) {\n  if (r < reps[index]) {\n    state.weight -= 5%\n  }\n}"
                    ],
                    # Invalid examples (for testing)
                    ["// Missing closing brace\nif (true) { state.weight = 100lb"],
                    ["// Invalid format\nSquat 3x5"],
                ],
                inputs=script_input,
                label="Example Scripts",
            )

    validate_btn.click(
        fn=validate_liftoscript, inputs=script_input, outputs=validation_output
    )

    # Additional interface for documentation - keeps UI clean but exposes to MCP
    gr.Markdown("---")
    gr.Markdown("### Documentation Access (for MCP)")
    with gr.Row():
        doc_topic = gr.Dropdown(
            choices=["overview", "syntax", "progressions", "all"],
            label="Documentation Topic",
            value="overview",
            scale=1,
        )
        doc_btn = gr.Button("Get Documentation", scale=1)
    doc_output = gr.JSON(label="Documentation")

    doc_btn.click(
        fn=get_liftoscript_documentation, inputs=doc_topic, outputs=doc_output
    )

    gr.Markdown("""
    ---
    
    ## 🎯 Value Proposition
    
    This minimal validator demonstrates that:
    1. **Syntax validation** can be separated from full program evaluation
    2. The Lezer parsers can be used **independently** 
    3. A standalone parser package would enable many tools and integrations
    
    ## 🀝 For Liftosaur Maintainers
    
    This PoC shows demand for a standalone parser package. Benefits:
    - Enable third-party tools (editors, linters, formatters)
    - Support AI assistants like Claude via MCP
    - Grow the Liftoscript ecosystem
    - Minimal maintenance burden (just the parser, not the full runtime)
    
    ## πŸ“š Resources
    - [Liftoscript Documentation](https://www.liftosaur.com/docs/docs/liftoscript)
    - [Liftosaur App](https://www.liftosaur.com/)
    - [GitHub Repository](https://github.com/astashov/liftosaur)
    - [Model Context Protocol](https://modelcontextprotocol.io/)
    """)

# Launch with MCP server enabled
if __name__ == "__main__":
    app.launch(mcp_server=True, server_name="0.0.0.0", share=False)