framework-m-studio 0.2.2__py3-none-any.whl → 0.3.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.
@@ -0,0 +1,752 @@
1
+ """Framework M Studio CLI Commands.
2
+
3
+ This module provides CLI commands that are registered via entry points
4
+ when framework-m-studio is installed. These extend the base `m` CLI
5
+ with developer tools.
6
+
7
+ Entry Point Registration (pyproject.toml):
8
+ [project.entry-points."framework_m.cli_commands"]
9
+ codegen = "framework_m_studio.cli:codegen_app"
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Annotated
16
+
17
+ import cyclopts
18
+
19
+ if TYPE_CHECKING:
20
+ from framework_m_studio.checklist_parser import ChecklistItem
21
+
22
+ # =============================================================================
23
+ # Codegen Sub-App
24
+ # =============================================================================
25
+
26
+ codegen_app = cyclopts.App(
27
+ name="codegen",
28
+ help="Code generation tools for Framework M",
29
+ )
30
+
31
+
32
+ @codegen_app.command(name="client")
33
+ def codegen_client(
34
+ lang: Annotated[
35
+ str,
36
+ cyclopts.Parameter(
37
+ name="--lang",
38
+ help="Target language: ts (TypeScript) or py (Python)",
39
+ ),
40
+ ] = "ts",
41
+ out: Annotated[
42
+ str,
43
+ cyclopts.Parameter(
44
+ name="--out",
45
+ help="Output directory for generated code",
46
+ ),
47
+ ] = "./generated",
48
+ openapi_url: Annotated[
49
+ str,
50
+ cyclopts.Parameter(
51
+ name="--openapi-url",
52
+ help="URL to fetch OpenAPI schema from",
53
+ ),
54
+ ] = "http://localhost:8000/schema/openapi.json",
55
+ ) -> None:
56
+ """Generate API client from OpenAPI schema.
57
+
58
+ Examples:
59
+ m codegen client --lang ts --out ./frontend/src/api
60
+ m codegen client --lang py --out ./scripts/api_client
61
+ """
62
+ from pathlib import Path
63
+
64
+ from framework_m_studio.sdk_generator import (
65
+ fetch_openapi_schema,
66
+ generate_typescript_client,
67
+ generate_typescript_types,
68
+ )
69
+
70
+ print(f"Generating {lang.upper()} client...")
71
+ print(f" OpenAPI URL: {openapi_url}")
72
+ print(f" Output: {out}")
73
+
74
+ # Create output directory
75
+ output_path = Path(out)
76
+ output_path.mkdir(parents=True, exist_ok=True)
77
+
78
+ # Fetch schema and generate
79
+ schema = fetch_openapi_schema(openapi_url)
80
+ if lang.lower() == "ts":
81
+ types_code = generate_typescript_types(schema)
82
+ client_code = generate_typescript_client(schema)
83
+ (output_path / "types.ts").write_text(types_code)
84
+ (output_path / "client.ts").write_text(client_code)
85
+
86
+
87
+ @codegen_app.command(name="doctype")
88
+ def codegen_doctype(
89
+ name: Annotated[
90
+ str,
91
+ cyclopts.Parameter(help="DocType class name (PascalCase)"),
92
+ ],
93
+ app: Annotated[
94
+ str | None,
95
+ cyclopts.Parameter(
96
+ name="--app",
97
+ help="Target app directory",
98
+ ),
99
+ ] = None,
100
+ ) -> None:
101
+ """Generate DocType Python code from schema.
102
+
103
+ This is the programmatic version of the Studio UI's
104
+ DocType builder. Useful for CI/CD pipelines.
105
+
106
+ Examples:
107
+ m codegen doctype Invoice --app apps/billing
108
+ """
109
+ print(f"Generating DocType: {name}")
110
+ if app:
111
+ print(f" Target app: {app}")
112
+ print()
113
+ print("⚠️ Not yet implemented. Coming in Phase 07.")
114
+ print(" Will use LibCST for code generation")
115
+
116
+
117
+ # =============================================================================
118
+ # Docs Sub-App (Optional - registered via separate entry point if needed)
119
+ # =============================================================================
120
+
121
+ docs_app = cyclopts.App(
122
+ name="docs",
123
+ help="Documentation generation tools",
124
+ )
125
+
126
+
127
+ @docs_app.command(name="generate")
128
+ def docs_generate(
129
+ output: Annotated[
130
+ str,
131
+ cyclopts.Parameter(
132
+ name="--output",
133
+ help="Output directory for documentation",
134
+ ),
135
+ ] = "./docs/developer/generated",
136
+ openapi_url: Annotated[
137
+ str | None,
138
+ cyclopts.Parameter(
139
+ name="--openapi-url",
140
+ help="URL to fetch OpenAPI schema from (optional)",
141
+ ),
142
+ ] = None,
143
+ protocols: Annotated[
144
+ bool,
145
+ cyclopts.Parameter(
146
+ name="--protocols",
147
+ help="Generate Protocol reference documentation",
148
+ ),
149
+ ] = False,
150
+ protocols_dir: Annotated[
151
+ str | None,
152
+ cyclopts.Parameter(
153
+ name="--protocols-dir",
154
+ help="Directory containing Protocol interfaces",
155
+ ),
156
+ ] = None,
157
+ ) -> None:
158
+ """Generate API documentation from DocTypes.
159
+
160
+ Examples:
161
+ m docs generate --output ./docs/developer/generated
162
+ m docs generate --protocols --protocols-dir libs/framework-m-core/src/framework_m_core/interfaces
163
+ """
164
+ from pathlib import Path
165
+
166
+ from framework_m_studio.docs_generator import run_docs_generate
167
+
168
+ # Use current working directory as project root
169
+ project_root = Path.cwd()
170
+
171
+ # Look in src/doctypes if it exists, otherwise use project root
172
+ doctypes_dir = project_root / "src" / "doctypes"
173
+ scan_root = project_root / "src" if doctypes_dir.exists() else project_root
174
+
175
+ run_docs_generate(
176
+ output=output,
177
+ project_root=str(scan_root),
178
+ openapi_url=openapi_url,
179
+ )
180
+
181
+ # Generate Protocol documentation if requested
182
+ if protocols:
183
+ from framework_m_studio.protocol_scanner import (
184
+ generate_protocol_markdown,
185
+ scan_protocols,
186
+ )
187
+
188
+ if protocols_dir:
189
+ interfaces_path = Path(protocols_dir)
190
+ else:
191
+ # Default to framework-m-core interfaces
192
+ interfaces_path = (
193
+ project_root
194
+ / "libs"
195
+ / "framework-m-core"
196
+ / "src"
197
+ / "framework_m_core"
198
+ / "interfaces"
199
+ )
200
+
201
+ if interfaces_path.exists():
202
+ print(f"\n📜 Generating Protocol documentation from {interfaces_path}...")
203
+ output_path = Path(output) / "protocols"
204
+ output_path.mkdir(parents=True, exist_ok=True)
205
+
206
+ protocol_list = scan_protocols(interfaces_path)
207
+ for proto in protocol_list:
208
+ markdown = generate_protocol_markdown(proto)
209
+ filename = proto["name"].lower() + ".md"
210
+ (output_path / filename).write_text(markdown)
211
+ print(f" ✓ {proto['name']}")
212
+
213
+ print(f" Generated {len(protocol_list)} Protocol reference docs")
214
+ else:
215
+ print(f"\n⚠️ Protocols directory not found: {interfaces_path}")
216
+
217
+
218
+ @docs_app.command(name="export")
219
+ def docs_export(
220
+ output: Annotated[
221
+ str,
222
+ cyclopts.Parameter(
223
+ name="--output",
224
+ help="Output file path for the RAG corpus (.jsonl)",
225
+ ),
226
+ ] = "./docs/machine/corpus.jsonl",
227
+ include_tests: Annotated[
228
+ bool,
229
+ cyclopts.Parameter(
230
+ name="--include-tests",
231
+ help="Include tests in the corpus",
232
+ ),
233
+ ] = False,
234
+ ) -> None:
235
+ """Export documentation as a machine-readable JSONL corpus.
236
+
237
+ Examples:
238
+ m docs export --output ./docs/machine/corpus.jsonl
239
+ """
240
+ from framework_m_studio.docs_generator import run_docs_export
241
+
242
+ run_docs_export(
243
+ output=output,
244
+ include_tests=include_tests,
245
+ )
246
+
247
+
248
+ @docs_app.command(name="adr")
249
+ def docs_adr(
250
+ action: Annotated[
251
+ str,
252
+ cyclopts.Parameter(help="Action: create or index"),
253
+ ],
254
+ title: Annotated[
255
+ str | None,
256
+ cyclopts.Parameter(help="Title for new ADR (required for create)"),
257
+ ] = None,
258
+ ) -> None:
259
+ """Manage Architecture Decision Records.
260
+
261
+ Examples:
262
+ m docs adr create "Use Redis for caching"
263
+ m docs adr index
264
+ """
265
+ import re
266
+ from datetime import date
267
+ from pathlib import Path
268
+
269
+ project_root = Path.cwd()
270
+ adr_dir = project_root / "docs" / "adr"
271
+ template_path = project_root / "docs" / "processes" / "adr-template.md"
272
+
273
+ if action == "create":
274
+ if not title:
275
+ print('❌ Title required: m docs adr create "Your Title"')
276
+ return
277
+
278
+ # Find next ADR number
279
+ existing_adrs = list(adr_dir.glob("*.md"))
280
+ numbers = []
281
+ for f in existing_adrs:
282
+ match = re.match(r"(\d+)-", f.name)
283
+ if match:
284
+ numbers.append(int(match.group(1)))
285
+
286
+ next_num = max(numbers, default=0) + 1
287
+ num_str = f"{next_num:04d}"
288
+
289
+ # Create slug from title
290
+ slug = re.sub(r"[^\w\s-]", "", title.lower())
291
+ slug = re.sub(r"[-\s]+", "-", slug).strip("-")
292
+
293
+ filename = f"{num_str}-{slug}.md"
294
+ filepath = adr_dir / filename
295
+
296
+ # Read template and fill in
297
+ if template_path.exists():
298
+ content = template_path.read_text()
299
+ content = content.replace("ADR-0000", f"ADR-{num_str}")
300
+ content = content.replace("[Short Title]", title)
301
+ content = content.replace("YYYY-MM-DD", date.today().isoformat())
302
+ else:
303
+ content = f"""# ADR-{num_str}: {title}
304
+
305
+ - **Status**: Proposed
306
+ - **Date**: {date.today().isoformat()}
307
+
308
+ ## Context
309
+
310
+ [Problem description]
311
+
312
+ ## Decision
313
+
314
+ [What we decided]
315
+
316
+ ## Consequences
317
+
318
+ [What becomes easier or harder]
319
+ """
320
+
321
+ adr_dir.mkdir(parents=True, exist_ok=True)
322
+ filepath.write_text(content)
323
+ print(f"✅ Created: {filepath}")
324
+
325
+ elif action == "index":
326
+ _generate_adr_index(adr_dir, "ADR")
327
+
328
+ else:
329
+ print(f"❌ Unknown action: {action}. Use 'create' or 'index'.")
330
+
331
+
332
+ @docs_app.command(name="rfc")
333
+ def docs_rfc(
334
+ action: Annotated[
335
+ str,
336
+ cyclopts.Parameter(help="Action: create or index"),
337
+ ],
338
+ title: Annotated[
339
+ str | None,
340
+ cyclopts.Parameter(help="Title for new RFC (required for create)"),
341
+ ] = None,
342
+ ) -> None:
343
+ """Manage Request for Comments documents.
344
+
345
+ Examples:
346
+ m docs rfc create "New Event System Design"
347
+ m docs rfc index
348
+ """
349
+ import re
350
+ from datetime import date
351
+ from pathlib import Path
352
+
353
+ project_root = Path.cwd()
354
+ rfc_dir = project_root / "docs" / "rfcs"
355
+ template_path = project_root / "docs" / "processes" / "rfc-template.md"
356
+
357
+ if action == "create":
358
+ if not title:
359
+ print('❌ Title required: m docs rfc create "Your Title"')
360
+ return
361
+
362
+ # Find next RFC number
363
+ existing_rfcs = list(rfc_dir.glob("*.md")) if rfc_dir.exists() else []
364
+ numbers = []
365
+ for f in existing_rfcs:
366
+ match = re.match(r"(\d+)-", f.name)
367
+ if match:
368
+ numbers.append(int(match.group(1)))
369
+
370
+ next_num = max(numbers, default=0) + 1
371
+ num_str = f"{next_num:04d}"
372
+
373
+ # Create slug from title
374
+ slug = re.sub(r"[^\w\s-]", "", title.lower())
375
+ slug = re.sub(r"[-\s]+", "-", slug).strip("-")
376
+
377
+ filename = f"{num_str}-{slug}.md"
378
+ filepath = rfc_dir / filename
379
+
380
+ # Read template and fill in
381
+ if template_path.exists():
382
+ content = template_path.read_text()
383
+ content = content.replace("RFC-0000", f"RFC-{num_str}")
384
+ content = content.replace("[Short Title]", title)
385
+ content = content.replace("YYYY-MM-DD", date.today().isoformat())
386
+ else:
387
+ content = f"""# RFC-{num_str}: {title}
388
+
389
+ - **Status**: Draft
390
+ - **Date**: {date.today().isoformat()}
391
+
392
+ ## Summary
393
+
394
+ [Brief description]
395
+
396
+ ## Motivation
397
+
398
+ [Why are we doing this?]
399
+
400
+ ## Detailed Design
401
+
402
+ [Technical details]
403
+ """
404
+
405
+ rfc_dir.mkdir(parents=True, exist_ok=True)
406
+ filepath.write_text(content)
407
+ print(f"✅ Created: {filepath}")
408
+
409
+ elif action == "index":
410
+ _generate_adr_index(rfc_dir, "RFC")
411
+
412
+ else:
413
+ print(f"❌ Unknown action: {action}. Use 'create' or 'index'.")
414
+
415
+
416
+ @docs_app.command(name="features")
417
+ def docs_features(
418
+ output: Annotated[
419
+ str,
420
+ cyclopts.Parameter(
421
+ name="--output",
422
+ help="Output file path for features page",
423
+ ),
424
+ ] = "./docs/developer/features.md",
425
+ ) -> None:
426
+ """Generate features documentation from checklists.
427
+
428
+ Examples:
429
+ m docs features --output ./docs/developer/features.md
430
+ """
431
+ from pathlib import Path
432
+
433
+ from framework_m_studio.checklist_parser import (
434
+ generate_features_summary,
435
+ scan_all_checklists,
436
+ )
437
+
438
+ project_root = Path.cwd()
439
+ checklists_dir = project_root / "checklists"
440
+
441
+ if not checklists_dir.exists():
442
+ print(f"❌ Checklists directory not found: {checklists_dir}")
443
+ return
444
+
445
+ print("📊 Scanning checklists...")
446
+ phases = scan_all_checklists(checklists_dir)
447
+
448
+ print(f" Found {len(phases)} phases")
449
+ total_items = sum(len(p.items) for p in phases)
450
+ completed_items = sum(len([i for i in p.items if i.completed]) for p in phases)
451
+ print(f" Total: {completed_items}/{total_items} items completed")
452
+
453
+ print("\n📝 Generating features page...")
454
+ summary = generate_features_summary(phases)
455
+
456
+ output_path = Path(output)
457
+ output_path.parent.mkdir(parents=True, exist_ok=True)
458
+ output_path.write_text(summary)
459
+
460
+ print(f"✅ Features page generated: {output_path}")
461
+
462
+
463
+ @docs_app.command(name="release-notes")
464
+ def docs_release_notes(
465
+ version: Annotated[
466
+ str,
467
+ cyclopts.Parameter(help="Version string (e.g., 1.0.0)"),
468
+ ],
469
+ compare_ref: Annotated[
470
+ str | None,
471
+ cyclopts.Parameter(
472
+ name="--compare",
473
+ help="Git ref to compare against (e.g., v0.9.0)",
474
+ ),
475
+ ] = None,
476
+ output: Annotated[
477
+ str | None,
478
+ cyclopts.Parameter(
479
+ name="--output",
480
+ help="Output file path for release notes",
481
+ ),
482
+ ] = None,
483
+ ) -> None:
484
+ """Generate release notes from checklist changes.
485
+
486
+ Examples:
487
+ m docs release-notes 1.0.0 --compare v0.9.0
488
+ m docs release-notes 1.0.0 --compare HEAD~1 --output CHANGELOG.md
489
+ """
490
+ import subprocess
491
+ from pathlib import Path
492
+
493
+ from framework_m_studio.checklist_parser import (
494
+ compare_versions,
495
+ generate_release_notes,
496
+ parse_checklist_file,
497
+ )
498
+
499
+ project_root = Path.cwd()
500
+ checklists_dir = project_root / "checklists"
501
+
502
+ if not compare_ref:
503
+ print("❌ --compare ref required (e.g., --compare v0.9.0)")
504
+ return
505
+
506
+ print(f"📊 Comparing checklists: {compare_ref} vs current")
507
+
508
+ # Get list of checklist files
509
+ current_files = list(checklists_dir.glob("phase-*.md"))
510
+
511
+ all_changes: dict[str, list[ChecklistItem]] = {
512
+ "newly_completed": [],
513
+ "newly_added": [],
514
+ "removed": [],
515
+ }
516
+
517
+ for current_file in current_files:
518
+ # Get old version from git
519
+ relative_path = current_file.relative_to(project_root)
520
+ try:
521
+ result = subprocess.run(
522
+ ["git", "show", f"{compare_ref}:{relative_path}"],
523
+ cwd=project_root,
524
+ capture_output=True,
525
+ text=True,
526
+ check=True,
527
+ )
528
+ old_content = result.stdout
529
+
530
+ # Parse old version
531
+ old_temp = project_root / ".temp_old_checklist.md"
532
+ old_temp.write_text(old_content)
533
+ old_phase = parse_checklist_file(old_temp)
534
+ old_temp.unlink()
535
+
536
+ # Parse current version
537
+ current_phase = parse_checklist_file(current_file)
538
+
539
+ # Compare
540
+ changes = compare_versions(old_phase.items, current_phase.items)
541
+
542
+ all_changes["newly_completed"].extend(changes["newly_completed"])
543
+ all_changes["newly_added"].extend(changes["newly_added"])
544
+ all_changes["removed"].extend(changes["removed"])
545
+
546
+ except subprocess.CalledProcessError:
547
+ # File didn't exist in old version
548
+ continue
549
+
550
+ print(f" Newly completed: {len(all_changes['newly_completed'])}")
551
+ print(f" Newly added: {len(all_changes['newly_added'])}")
552
+
553
+ print("\n📝 Generating release notes...")
554
+ notes = generate_release_notes(all_changes, version)
555
+
556
+ if output:
557
+ output_path = Path(output)
558
+ output_path.parent.mkdir(parents=True, exist_ok=True)
559
+
560
+ # Prepend to existing changelog if it exists
561
+ if output_path.exists():
562
+ existing = output_path.read_text()
563
+ notes = notes + "\n\n---\n\n" + existing
564
+
565
+ output_path.write_text(notes)
566
+ print(f"✅ Release notes written to: {output_path}")
567
+ else:
568
+ print("\n" + notes)
569
+
570
+
571
+ def _generate_adr_index(docs_dir: Path, doc_type: str) -> None:
572
+ """Generate index.md and Docusaurus sidebar for ADRs or RFCs."""
573
+ import json
574
+ import re
575
+
576
+ if not docs_dir.exists():
577
+ print(f"❌ Directory not found: {docs_dir}")
578
+ return
579
+
580
+ # Collect all documents
581
+ docs = []
582
+ for f in sorted(docs_dir.glob("*.md")):
583
+ if f.name in ("index.md", "_category_.json"):
584
+ continue
585
+
586
+ match = re.match(r"(\d+)-(.+)\.md", f.name)
587
+ if match:
588
+ num = match.group(1)
589
+ # Extract title from file
590
+ content = f.read_text()
591
+ title_match = re.search(r"^#\s+.+:\s*(.+)$", content, re.MULTILINE)
592
+ title = (
593
+ title_match.group(1)
594
+ if title_match
595
+ else match.group(2).replace("-", " ").title()
596
+ )
597
+
598
+ # Extract status
599
+ status_match = re.search(r"\*\*Status\*\*:\s*(\w+)", content)
600
+ status = status_match.group(1) if status_match else "Unknown"
601
+
602
+ docs.append(
603
+ {
604
+ "num": num,
605
+ "title": title,
606
+ "status": status,
607
+ "filename": f.name,
608
+ "slug": f.stem, # filename without .md
609
+ }
610
+ )
611
+
612
+ # Generate index.md
613
+ lines = [
614
+ f"# {doc_type} Index",
615
+ "",
616
+ f"This index contains all {doc_type}s in chronological order.",
617
+ "",
618
+ "| # | Title | Status |",
619
+ "|---|-------|--------|",
620
+ ]
621
+
622
+ for doc in docs:
623
+ lines.append(
624
+ f"| [{doc['num']}](./{doc['filename']}) | {doc['title']} | {doc['status']} |"
625
+ )
626
+
627
+ lines.append("")
628
+ lines.append(f"_Total: {len(docs)} {doc_type}s_")
629
+
630
+ index_path = docs_dir / "index.md"
631
+ index_path.write_text("\n".join(lines))
632
+ print(f"✅ Generated: {index_path} ({len(docs)} {doc_type}s)")
633
+
634
+ # Generate Docusaurus _category_.json for sidebar with lazy-loading
635
+ category_config = {
636
+ "label": f"{doc_type}s",
637
+ "position": 1,
638
+ "collapsed": True, # Enable lazy-loading by default collapsed
639
+ "collapsible": True,
640
+ "link": {
641
+ "type": "doc",
642
+ "id": "index",
643
+ },
644
+ }
645
+ category_path = docs_dir / "_category_.json"
646
+ category_path.write_text(json.dumps(category_config, indent=2))
647
+ print(f"✅ Generated: {category_path} (Docusaurus sidebar)")
648
+
649
+ # Generate sidebar items JSON for programmatic use
650
+ sidebar_items = {
651
+ "type": "category",
652
+ "label": f"{doc_type}s",
653
+ "collapsed": True,
654
+ "items": [
655
+ {
656
+ "type": "doc",
657
+ "id": f"{doc_type.lower()}/{doc['slug']}",
658
+ "label": f"{doc_type}-{doc['num']}: {doc['title'][:40]}{'...' if len(doc['title']) > 40 else ''}",
659
+ }
660
+ for doc in docs
661
+ ],
662
+ }
663
+ sidebar_path = docs_dir / "_sidebar.json"
664
+ sidebar_path.write_text(json.dumps(sidebar_items, indent=2))
665
+ print(f"✅ Generated: {sidebar_path} (sidebar items)")
666
+
667
+
668
+ # =============================================================================
669
+ # Studio Sub-App (Main command to start Studio server)
670
+ # =============================================================================
671
+
672
+ studio_app = cyclopts.App(
673
+ name="studio",
674
+ help="Start Framework M Studio visual editor",
675
+ )
676
+
677
+
678
+ @studio_app.default
679
+ def studio_serve(
680
+ port: Annotated[
681
+ int,
682
+ cyclopts.Parameter(
683
+ name="--port",
684
+ help="Port to run Studio on",
685
+ ),
686
+ ] = 9000,
687
+ host: Annotated[
688
+ str,
689
+ cyclopts.Parameter(
690
+ name="--host",
691
+ help="Host to bind to",
692
+ ),
693
+ ] = "127.0.0.1",
694
+ reload: Annotated[
695
+ bool,
696
+ cyclopts.Parameter(
697
+ name="--reload",
698
+ help="Enable auto-reload for development",
699
+ ),
700
+ ] = False,
701
+ cloud: Annotated[
702
+ bool,
703
+ cyclopts.Parameter(
704
+ name="--cloud",
705
+ help="Enable cloud mode (Git-backed workspaces)",
706
+ ),
707
+ ] = False,
708
+ ) -> None:
709
+ """Start Framework M Studio.
710
+
711
+ Examples:
712
+ m studio # Start on port 9000
713
+ m studio --port 8000 # Custom port
714
+ m studio --reload # Development mode
715
+ m studio --cloud # Enable cloud mode
716
+ """
717
+ import os
718
+
719
+ import uvicorn
720
+
721
+ # Print startup banner
722
+ print()
723
+ print("🎨 Starting Framework M Studio")
724
+ print(f" ➜ Local: http://{host}:{port}/studio/")
725
+ print(f" ➜ API: http://{host}:{port}/studio/api/")
726
+ print(f" 🔌 API Health: http://{host}:{port}/studio/api/health")
727
+ print()
728
+
729
+ if cloud:
730
+ print("☁️ Cloud mode enabled - Git-backed workspaces")
731
+ os.environ["STUDIO_CLOUD_MODE"] = "1"
732
+ print()
733
+
734
+ # Start uvicorn
735
+ uvicorn.run(
736
+ "framework_m_studio.app:app",
737
+ host=host,
738
+ port=port,
739
+ reload=reload,
740
+ log_level="info",
741
+ )
742
+
743
+
744
+ __all__ = [
745
+ "codegen_app",
746
+ "codegen_client",
747
+ "codegen_doctype",
748
+ "docs_app",
749
+ "docs_generate",
750
+ "studio_app",
751
+ "studio_serve",
752
+ ]