framework-m-studio 0.2.3__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.
- framework_m_studio/__init__.py +6 -1
- framework_m_studio/app.py +56 -11
- framework_m_studio/checklist_parser.py +421 -0
- framework_m_studio/cli/__init__.py +752 -0
- framework_m_studio/cli/build.py +421 -0
- framework_m_studio/cli/dev.py +214 -0
- framework_m_studio/cli/new.py +754 -0
- framework_m_studio/cli/quality.py +157 -0
- framework_m_studio/cli/studio.py +159 -0
- framework_m_studio/cli/utility.py +50 -0
- framework_m_studio/codegen/generator.py +6 -2
- framework_m_studio/codegen/parser.py +101 -4
- framework_m_studio/codegen/templates/doctype.py.jinja2 +19 -10
- framework_m_studio/codegen/test_generator.py +6 -2
- framework_m_studio/discovery.py +15 -5
- framework_m_studio/docs_generator.py +298 -2
- framework_m_studio/protocol_scanner.py +435 -0
- framework_m_studio/routes.py +39 -11
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/METADATA +7 -2
- framework_m_studio-0.3.0.dist-info/RECORD +32 -0
- framework_m_studio-0.3.0.dist-info/entry_points.txt +18 -0
- framework_m_studio/cli.py +0 -247
- framework_m_studio/static/assets/index-BJ5Noua8.js +0 -171
- framework_m_studio/static/assets/index-CnPUX2YK.css +0 -1
- framework_m_studio/static/favicon.ico +0 -0
- framework_m_studio/static/index.html +0 -40
- framework_m_studio-0.2.3.dist-info/RECORD +0 -28
- framework_m_studio-0.2.3.dist-info/entry_points.txt +0 -4
- {framework_m_studio-0.2.3.dist-info → framework_m_studio-0.3.0.dist-info}/WHEEL +0 -0
|
@@ -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
|
+
]
|