muscles-cli 1.0.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.
- muscles/cli/Makefile +26 -0
- muscles/cli/__about__.py +6 -0
- muscles/cli/__init__.py +66 -0
- muscles/cli/__main__.py +5 -0
- muscles/cli/cli/__init__.py +6 -0
- muscles/cli/cli/cli.py +44 -0
- muscles/cli/cli/colory.py +15 -0
- muscles/cli/cli/command.py +778 -0
- muscles/cli/cli/constants.py +3 -0
- muscles/cli/cli/error_handler.py +6 -0
- muscles/cli/cli/func.py +221 -0
- muscles/cli/cli/instance.py +47 -0
- muscles/cli/cli/strategy.py +111 -0
- muscles/cli/providers.py +198 -0
- muscles/cli/streaming.py +50 -0
- muscles/cli/tooling.py +927 -0
- muscles_cli-1.0.0.dist-info/METADATA +165 -0
- muscles_cli-1.0.0.dist-info/RECORD +20 -0
- muscles_cli-1.0.0.dist-info/WHEEL +4 -0
- muscles_cli-1.0.0.dist-info/entry_points.txt +3 -0
muscles/cli/tooling.py
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from muscles.core import ActionDispatcher
|
|
15
|
+
from muscles.core import ActionError
|
|
16
|
+
from muscles.core import ActionExecutionError
|
|
17
|
+
from muscles.core import ActionNotFound
|
|
18
|
+
from muscles.core import ActionPermissionDenied
|
|
19
|
+
from muscles.core import ActionValidationError
|
|
20
|
+
from muscles.core import inspect_application
|
|
21
|
+
from muscles.core import resolve_runtime_mode
|
|
22
|
+
from muscles.core import GenerationRequest
|
|
23
|
+
from .providers import default_generator_registry
|
|
24
|
+
from .streaming import render_stream_result
|
|
25
|
+
|
|
26
|
+
APP_TEMPLATE = """from muscles import ApplicationMeta, Configurator, Context
|
|
27
|
+
from muscles.{runtime} import {strategy}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class App(metaclass=ApplicationMeta):
|
|
31
|
+
config = Configurator(obj={{"main": {{"HOST": "0.0.0.0", "PORT": "8080"}}}})
|
|
32
|
+
context = Context({strategy}, params={{}})
|
|
33
|
+
|
|
34
|
+
def run(self, *args):
|
|
35
|
+
return self.context.execute(*args, shutup=True)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
CLI_APP_TEMPLATE = """from muscles import ApplicationMeta, Context
|
|
39
|
+
from muscles.cli import CliStrategy, cli
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class App(metaclass=ApplicationMeta):
|
|
43
|
+
context = Context(CliStrategy, params={{}})
|
|
44
|
+
|
|
45
|
+
def run(self, *args):
|
|
46
|
+
return self.context.execute(*args, shutup=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.group()
|
|
50
|
+
def bookings(*args):
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@bookings.command(command_name="list")
|
|
55
|
+
def list_bookings(*args):
|
|
56
|
+
return "ok"
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
README_TEMPLATE = """# {project_name}
|
|
60
|
+
|
|
61
|
+
Generated with `muscles new`.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
See canonical install matrix:
|
|
66
|
+
https://github.com/butkoden/muscles/blob/master/docs/installation.md
|
|
67
|
+
|
|
68
|
+
## Test
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
PYTHONPATH=. python -m pytest -q
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
TEST_TEMPLATE = """def test_smoke():
|
|
76
|
+
assert True
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
PYPROJECT_TEMPLATE = """[build-system]
|
|
80
|
+
requires = ["setuptools>=69.0.2"]
|
|
81
|
+
build-backend = "setuptools.build_meta"
|
|
82
|
+
|
|
83
|
+
[project]
|
|
84
|
+
name = "{project_slug}"
|
|
85
|
+
version = "0.1.0"
|
|
86
|
+
description = "Generated Muscles project"
|
|
87
|
+
requires-python = ">=3.9"
|
|
88
|
+
dependencies = [
|
|
89
|
+
"muscles",
|
|
90
|
+
]
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
DOCKERFILE_TEMPLATE = """FROM python:3.12-slim
|
|
94
|
+
WORKDIR /app
|
|
95
|
+
COPY . .
|
|
96
|
+
CMD ["python", "-m", "pytest", "-q"]
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
DOCKER_COMPOSE_TEMPLATE = """services:
|
|
100
|
+
app:
|
|
101
|
+
build: .
|
|
102
|
+
command: python -m pytest -q
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
EXIT_SUCCESS = 0
|
|
107
|
+
EXIT_INVALID_ARGUMENT = 2
|
|
108
|
+
EXIT_RUNTIME_ERROR = 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
AI_DSL_CHEAT_SHEET = """AI-friendly command DSL:
|
|
115
|
+
- action: API action name, e.g. `bookings.inspect`, `health.check`
|
|
116
|
+
- transport: execution transport (`asgi`, `wsgi`, `cli`, `full`)
|
|
117
|
+
- context: `Context(...)` in App settings
|
|
118
|
+
- rule: validation/route/parse rule name
|
|
119
|
+
- schema: data schema class in request/response
|
|
120
|
+
- resource: route prefix object (for domain groups)
|
|
121
|
+
- query: list-like command to fetch many entries
|
|
122
|
+
- status: command outcome (`ok`, `warn`, `error`)
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write(path: Path, content: str) -> None:
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
path.write_text(content, encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scaffold_project(target: Path, runtime: str = "asgi", force: bool = False) -> list[str]:
|
|
132
|
+
runtime = runtime.lower()
|
|
133
|
+
if runtime not in {"asgi", "wsgi", "cli", "full"}:
|
|
134
|
+
raise ValueError(f"Unsupported runtime `{runtime}`")
|
|
135
|
+
|
|
136
|
+
if target.exists() and any(target.iterdir()) and not force:
|
|
137
|
+
raise FileExistsError(f"Target directory `{target}` is not empty. Use --force to overwrite.")
|
|
138
|
+
|
|
139
|
+
if target.exists() and force:
|
|
140
|
+
for entry in target.iterdir():
|
|
141
|
+
if entry.is_dir():
|
|
142
|
+
shutil.rmtree(entry)
|
|
143
|
+
else:
|
|
144
|
+
entry.unlink()
|
|
145
|
+
|
|
146
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
app_dir = target / "app"
|
|
148
|
+
|
|
149
|
+
dirs = [
|
|
150
|
+
app_dir / "domain",
|
|
151
|
+
app_dir / "schemas",
|
|
152
|
+
app_dir / "web",
|
|
153
|
+
app_dir / "api",
|
|
154
|
+
app_dir / "cli",
|
|
155
|
+
app_dir / "rules",
|
|
156
|
+
app_dir / "templates",
|
|
157
|
+
app_dir / "static",
|
|
158
|
+
target / "tests",
|
|
159
|
+
]
|
|
160
|
+
for d in dirs:
|
|
161
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
strategy = {"asgi": "AsgiStrategy", "wsgi": "WsgiStrategy", "full": "WsgiStrategy"}.get(runtime)
|
|
164
|
+
app_runtime = {"asgi": "asgi", "wsgi": "wsgi", "full": "wsgi"}.get(runtime)
|
|
165
|
+
|
|
166
|
+
if runtime == "cli":
|
|
167
|
+
application = CLI_APP_TEMPLATE
|
|
168
|
+
else:
|
|
169
|
+
application = APP_TEMPLATE.format(runtime=app_runtime, strategy=strategy)
|
|
170
|
+
|
|
171
|
+
_write(app_dir / "__init__.py", "")
|
|
172
|
+
_write(app_dir / "application.py", application)
|
|
173
|
+
_write(app_dir / "config.py", "APP_ENV = 'development'\n")
|
|
174
|
+
_write(target / "tests" / "test_smoke.py", TEST_TEMPLATE)
|
|
175
|
+
_write(target / "README.md", README_TEMPLATE.format(project_name=target.name))
|
|
176
|
+
_write(target / ".gitignore", "__pycache__/\n.pytest_cache/\n*.pyc\n")
|
|
177
|
+
_write(target / "pyproject.toml", PYPROJECT_TEMPLATE.format(project_slug=target.name.replace("_", "-")))
|
|
178
|
+
|
|
179
|
+
if runtime in {"asgi", "wsgi", "full"}:
|
|
180
|
+
_write(target / "Dockerfile", DOCKERFILE_TEMPLATE)
|
|
181
|
+
_write(target / "docker-compose.yml", DOCKER_COMPOSE_TEMPLATE)
|
|
182
|
+
|
|
183
|
+
created = []
|
|
184
|
+
for p in sorted(target.rglob("*")):
|
|
185
|
+
if p.is_file():
|
|
186
|
+
created.append(str(p.relative_to(target)))
|
|
187
|
+
return created
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def generate_artifact(
|
|
191
|
+
project_root: Path,
|
|
192
|
+
generator_type: str,
|
|
193
|
+
name: str,
|
|
194
|
+
force: bool = False,
|
|
195
|
+
with_tests: bool = True,
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
app_dir = project_root / "app"
|
|
198
|
+
if not app_dir.exists():
|
|
199
|
+
raise FileNotFoundError("Muscles project not detected: `app/` directory is missing.")
|
|
200
|
+
|
|
201
|
+
generator_type = generator_type.lower()
|
|
202
|
+
registry = default_generator_registry()
|
|
203
|
+
provider = registry.resolve(generator_type)
|
|
204
|
+
return provider.generate(
|
|
205
|
+
project_root=project_root,
|
|
206
|
+
request=GenerationRequest(
|
|
207
|
+
generator_type=generator_type,
|
|
208
|
+
name=name,
|
|
209
|
+
force=force,
|
|
210
|
+
with_tests=with_tests,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def detect_project_root(start: Path | None = None) -> Path:
|
|
216
|
+
current = (start or Path.cwd()).resolve()
|
|
217
|
+
for candidate in [current] + list(current.parents):
|
|
218
|
+
if (candidate / "app").exists() and (candidate / "tests").exists():
|
|
219
|
+
return candidate
|
|
220
|
+
raise FileNotFoundError("Muscles project root not found.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _entrypoint_candidates(project_root: Path) -> list[str]:
|
|
224
|
+
return [
|
|
225
|
+
"app.application:App",
|
|
226
|
+
"app.app:App",
|
|
227
|
+
"app.main:App",
|
|
228
|
+
"app.__init__:App",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _load_project_app(project_root: Path):
|
|
233
|
+
sys.path.insert(0, str(project_root))
|
|
234
|
+
last_error = None
|
|
235
|
+
for entrypoint in _entrypoint_candidates(project_root):
|
|
236
|
+
try:
|
|
237
|
+
return load_app_from_entrypoint(entrypoint)
|
|
238
|
+
except Exception as exc: # pragma: no cover - defensive only
|
|
239
|
+
last_error = exc
|
|
240
|
+
continue
|
|
241
|
+
raise last_error or ValueError("No runnable app entrypoint found.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _has_nested_cli_options(cli_root: Path) -> bool:
|
|
245
|
+
for path in cli_root.glob("**/*.py"):
|
|
246
|
+
content = path.read_text(encoding="utf-8")
|
|
247
|
+
if ".group(" in content and ".argument(" in content:
|
|
248
|
+
return True
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _has_map_model_autoincrement(sql_root: Path) -> bool:
|
|
253
|
+
for path in sql_root.glob("**/*.py"):
|
|
254
|
+
content = path.read_text(encoding="utf-8")
|
|
255
|
+
if "map_model(" in content and "Integer(" in content and "autoincrement" in content:
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _has_otel_hooks(project_root: Path) -> bool:
|
|
261
|
+
for path in project_root.glob("**/*.py"):
|
|
262
|
+
if path.name.startswith("."):
|
|
263
|
+
continue
|
|
264
|
+
content = path.read_text(encoding="utf-8")
|
|
265
|
+
if (
|
|
266
|
+
"opentelemetry" in content.lower()
|
|
267
|
+
or "otel" in content.lower()
|
|
268
|
+
or "from muscles import Watchdog" in content
|
|
269
|
+
or "Watchdog" in content
|
|
270
|
+
):
|
|
271
|
+
return True
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _collect_diagnostic_checks(payload: dict, check_key: str, status: str, message: str, code: str):
|
|
276
|
+
root_check = payload.setdefault("checks", {})
|
|
277
|
+
root_check[check_key] = {
|
|
278
|
+
"status": status,
|
|
279
|
+
"message": message,
|
|
280
|
+
"code": code,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def run_doctor(project_root: Path) -> dict:
|
|
284
|
+
errors = []
|
|
285
|
+
warnings = []
|
|
286
|
+
checks = {}
|
|
287
|
+
info = []
|
|
288
|
+
|
|
289
|
+
if not (project_root / "app").exists():
|
|
290
|
+
errors.append(
|
|
291
|
+
{
|
|
292
|
+
"severity": "error",
|
|
293
|
+
"code": "MISSING_APP_DIR",
|
|
294
|
+
"message": "Directory `app/` is missing.",
|
|
295
|
+
"suggested_fix": "Run `muscles new <name>` or restore `app/`.",
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
_collect_diagnostic_checks(checks, "routes", "fail", "app directory missing", "DOCS_ROUTES")
|
|
299
|
+
else:
|
|
300
|
+
_collect_diagnostic_checks(checks, "routes", "ok", "app directory exists", "DOCS_ROUTES")
|
|
301
|
+
|
|
302
|
+
if not (project_root / "tests").exists():
|
|
303
|
+
errors.append(
|
|
304
|
+
{
|
|
305
|
+
"severity": "error",
|
|
306
|
+
"code": "MISSING_TESTS_DIR",
|
|
307
|
+
"message": "Directory `tests/` is missing.",
|
|
308
|
+
"suggested_fix": "Add tests directory and baseline tests.",
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
_collect_diagnostic_checks(checks, "tests", "fail", "tests directory missing", "DOCS_TESTS")
|
|
312
|
+
else:
|
|
313
|
+
_collect_diagnostic_checks(checks, "tests", "ok", "tests directory exists", "DOCS_TESTS")
|
|
314
|
+
test_files = list((project_root / "tests").glob("test_*.py"))
|
|
315
|
+
if len(test_files) == 0:
|
|
316
|
+
errors.append(
|
|
317
|
+
{
|
|
318
|
+
"severity": "error",
|
|
319
|
+
"code": "MISSING_TESTS",
|
|
320
|
+
"message": "No test files matching tests/test_*.py.",
|
|
321
|
+
"suggested_fix": "Generate tests or add smoke/unit tests.",
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
_collect_diagnostic_checks(checks, "tests", "fail", "no test_*.py files found", "DOCS_TESTS_CONTENT")
|
|
325
|
+
else:
|
|
326
|
+
_collect_diagnostic_checks(checks, "tests", "ok", f"found {len(test_files)} test files", "DOCS_TESTS_CONTENT")
|
|
327
|
+
|
|
328
|
+
config_path = project_root / "app" / "config.py"
|
|
329
|
+
if config_path.exists():
|
|
330
|
+
body = config_path.read_text(encoding="utf-8")
|
|
331
|
+
if "TODO" in body:
|
|
332
|
+
warnings.append(
|
|
333
|
+
{
|
|
334
|
+
"severity": "warning",
|
|
335
|
+
"code": "CONFIG_TODO",
|
|
336
|
+
"message": "Config contains TODO markers.",
|
|
337
|
+
"suggested_fix": "Resolve placeholders in app/config.py.",
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
_collect_diagnostic_checks(checks, "config", "warn", "TODO marker in config", "DOCS_CONFIG")
|
|
341
|
+
else:
|
|
342
|
+
_collect_diagnostic_checks(checks, "config", "ok", "config parsed", "DOCS_CONFIG")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
app = _load_project_app(project_root)
|
|
346
|
+
payload = build_inspection_payload(app=app)
|
|
347
|
+
routes = payload.get("routes", [])
|
|
348
|
+
path_map = [item.get("path") for item in routes]
|
|
349
|
+
route_contract = payload.get("route_contract", {}).get("canonical", {})
|
|
350
|
+
required_canonical = set(route_contract.values())
|
|
351
|
+
missing_canonical = sorted(required_canonical - set(path_map))
|
|
352
|
+
if missing_canonical:
|
|
353
|
+
errors.append(
|
|
354
|
+
{
|
|
355
|
+
"severity": "error",
|
|
356
|
+
"code": "MISSING_CANONICAL_ROUTES",
|
|
357
|
+
"message": f"Missing canonical routes: {', '.join(missing_canonical)}",
|
|
358
|
+
"suggested_fix": "Enable rest API middleware or add routes manually.",
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
_collect_diagnostic_checks(checks, "routing", "fail", "some canonical routes are missing", "DOCS_ROUTING")
|
|
362
|
+
else:
|
|
363
|
+
_collect_diagnostic_checks(checks, "routing", "ok", "canonical routes found", "DOCS_ROUTING")
|
|
364
|
+
|
|
365
|
+
route_aliases = payload.get("route_contract", {}).get("aliases", {})
|
|
366
|
+
missing_aliases: list[str] = []
|
|
367
|
+
for alias, canonical in route_aliases.items():
|
|
368
|
+
if canonical not in path_map:
|
|
369
|
+
errors.append(
|
|
370
|
+
{
|
|
371
|
+
"severity": "error",
|
|
372
|
+
"code": "MISSING_CANONICAL_ALIAS_TARGET",
|
|
373
|
+
"message": f"Canonical target missing for alias {alias}: {canonical}",
|
|
374
|
+
"suggested_fix": "Check RestApi route contract setup.",
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
if alias not in path_map:
|
|
378
|
+
warnings.append(
|
|
379
|
+
{
|
|
380
|
+
"severity": "warning",
|
|
381
|
+
"code": "MISSING_ALIAS_ROUTE",
|
|
382
|
+
"message": f"Compatibility alias missing: {alias} -> {canonical}",
|
|
383
|
+
"suggested_fix": "Keep compatibility aliases until migration is complete.",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
missing_aliases.append(alias)
|
|
387
|
+
_collect_diagnostic_checks(
|
|
388
|
+
checks,
|
|
389
|
+
"route_aliases",
|
|
390
|
+
"ok" if len(missing_aliases) == 0 else "warn",
|
|
391
|
+
"all compatibility aliases found" if len(missing_aliases) == 0 else "compatibility aliases missing",
|
|
392
|
+
"DOCS_ROUTE_ALIAS",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
seen_routes = set()
|
|
396
|
+
for route in routes:
|
|
397
|
+
route_signature = (
|
|
398
|
+
route.get("path"),
|
|
399
|
+
(route.get("method") or "*").lower(),
|
|
400
|
+
(route.get("canonical") or route.get("path")),
|
|
401
|
+
)
|
|
402
|
+
if route_signature in seen_routes:
|
|
403
|
+
errors.append(
|
|
404
|
+
{
|
|
405
|
+
"severity": "error",
|
|
406
|
+
"code": "ROUTE_CONFLICT",
|
|
407
|
+
"message": f"Duplicate route signature: {route_signature}",
|
|
408
|
+
"suggested_fix": "Deduplicate route registration or split by method/content-type.",
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
seen_routes.add(route_signature)
|
|
412
|
+
|
|
413
|
+
_collect_diagnostic_checks(
|
|
414
|
+
checks,
|
|
415
|
+
"route_conflicts",
|
|
416
|
+
"ok" if not any(item["code"] == "ROUTE_CONFLICT" for item in errors) else "fail",
|
|
417
|
+
"checked route signatures",
|
|
418
|
+
"DOCS_ROUTE_CONFLICTS",
|
|
419
|
+
)
|
|
420
|
+
except Exception:
|
|
421
|
+
warnings.append(
|
|
422
|
+
{
|
|
423
|
+
"severity": "warning",
|
|
424
|
+
"code": "APP_INSPECT_FAILED",
|
|
425
|
+
"message": "Could not load app for contract checks.",
|
|
426
|
+
"suggested_fix": "Ensure app entrypoint imports safely; run from project directory.",
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
_collect_diagnostic_checks(checks, "routing", "warn", "app inspection failed", "DOCS_ROUTING")
|
|
430
|
+
|
|
431
|
+
cli_path = project_root / "app" / "cli"
|
|
432
|
+
if cli_path.exists():
|
|
433
|
+
nested_ok = _has_nested_cli_options(cli_path)
|
|
434
|
+
_collect_diagnostic_checks(
|
|
435
|
+
checks,
|
|
436
|
+
"cli_nested_options",
|
|
437
|
+
"ok" if nested_ok else "warn",
|
|
438
|
+
"nested CLI options parser detected" if nested_ok else "no nested option pattern detected",
|
|
439
|
+
"DOCS_CLI_NESTED",
|
|
440
|
+
)
|
|
441
|
+
if not nested_ok:
|
|
442
|
+
warnings.append(
|
|
443
|
+
{
|
|
444
|
+
"severity": "warning",
|
|
445
|
+
"code": "CLI_NESTED_OPTIONS",
|
|
446
|
+
"message": "No nested CLI patterns in app/cli discovered.",
|
|
447
|
+
"suggested_fix": "Ensure nested group options are described using `@group` + `@group.argument`.",
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
warnings.append(
|
|
452
|
+
{
|
|
453
|
+
"severity": "warning",
|
|
454
|
+
"code": "MISSING_CLI_DIR",
|
|
455
|
+
"message": "Directory `app/cli` is missing.",
|
|
456
|
+
"suggested_fix": "Add CLI modules under app/cli if CLI workflows are used.",
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
_collect_diagnostic_checks(checks, "cli_nested_options", "warn", "app/cli missing", "DOCS_CLI_NESTED")
|
|
460
|
+
|
|
461
|
+
models_dir = project_root / "app"
|
|
462
|
+
map_model_ok = _has_map_model_autoincrement(models_dir)
|
|
463
|
+
if map_model_ok:
|
|
464
|
+
_collect_diagnostic_checks(
|
|
465
|
+
checks,
|
|
466
|
+
"map_model_autoincrement",
|
|
467
|
+
"ok",
|
|
468
|
+
"map_model with autoincrement confirmed",
|
|
469
|
+
"DOCS_MAP_MODEL",
|
|
470
|
+
)
|
|
471
|
+
elif any("map_model(" in path.read_text(encoding="utf-8") for path in models_dir.glob("**/*.py")):
|
|
472
|
+
warnings.append(
|
|
473
|
+
{
|
|
474
|
+
"severity": "warning",
|
|
475
|
+
"code": "MAP_MODEL_AUTOINCREMENT",
|
|
476
|
+
"message": "map_model usage found, but autoincrement for int PK not guaranteed.",
|
|
477
|
+
"suggested_fix": "Set integer PK column with autoincrement for write models.",
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
_collect_diagnostic_checks(
|
|
481
|
+
checks,
|
|
482
|
+
"map_model_autoincrement",
|
|
483
|
+
"warn",
|
|
484
|
+
"map_model usage found, but autoincrement for int PK not guaranteed",
|
|
485
|
+
"DOCS_MAP_MODEL",
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
_collect_diagnostic_checks(
|
|
489
|
+
checks,
|
|
490
|
+
"map_model_autoincrement",
|
|
491
|
+
"warn",
|
|
492
|
+
"no map_model() usage with Integer autoincrement detected",
|
|
493
|
+
"DOCS_MAP_MODEL",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
otel_ok = _has_otel_hooks(project_root)
|
|
497
|
+
_collect_diagnostic_checks(
|
|
498
|
+
checks,
|
|
499
|
+
"otel",
|
|
500
|
+
"ok" if otel_ok else "warn",
|
|
501
|
+
"otel hook pattern detected" if otel_ok else "no otel/watchdog hooks detected",
|
|
502
|
+
"DOCS_OTEL",
|
|
503
|
+
)
|
|
504
|
+
if not otel_ok:
|
|
505
|
+
warnings.append(
|
|
506
|
+
{
|
|
507
|
+
"severity": "warning",
|
|
508
|
+
"code": "OTEL_HOOKS",
|
|
509
|
+
"message": "OTel/watchdog hooks not confirmed.",
|
|
510
|
+
"suggested_fix": "Add OTel bootstrap module and hook into app lifecycle.",
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
"checks": checks,
|
|
516
|
+
"errors": errors,
|
|
517
|
+
"warnings": warnings,
|
|
518
|
+
"info": info,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def run_project_tests(project_root: Path, timeout: float | int | None = None) -> int:
|
|
523
|
+
env = os.environ.copy()
|
|
524
|
+
env["PYTHONPATH"] = "." + (os.pathsep + env["PYTHONPATH"] if "PYTHONPATH" in env else "")
|
|
525
|
+
try:
|
|
526
|
+
process = subprocess.run(
|
|
527
|
+
["python", "-m", "pytest", "-q"],
|
|
528
|
+
cwd=str(project_root),
|
|
529
|
+
env=env,
|
|
530
|
+
check=False,
|
|
531
|
+
timeout=timeout if timeout and timeout > 0 else None,
|
|
532
|
+
)
|
|
533
|
+
return process.returncode
|
|
534
|
+
except subprocess.TimeoutExpired:
|
|
535
|
+
return 2
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
539
|
+
parser = argparse.ArgumentParser(
|
|
540
|
+
prog="muscles",
|
|
541
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
542
|
+
description="Muscles project utility CLI",
|
|
543
|
+
epilog=(
|
|
544
|
+
"AI/CLI Cheat Sheet:\n"
|
|
545
|
+
f"{AI_DSL_CHEAT_SHEET}\n"
|
|
546
|
+
"Examples:\n"
|
|
547
|
+
" muscles create/project scaffolding:: muscles new demo --runtime asgi\n"
|
|
548
|
+
" muscles inspect contract:: muscles inspect --json --app app.application:App\n"
|
|
549
|
+
" muscles doctor checks:: muscles doctor --json\n"
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _add_global_options(target_parser: argparse.ArgumentParser) -> None:
|
|
554
|
+
target_parser.add_argument("--json", action="store_true", help="JSON output when supported")
|
|
555
|
+
target_parser.add_argument("--machine", action="store_true", help="Machine-readable mode (stdout JSON only)")
|
|
556
|
+
target_parser.add_argument("--quiet", action="store_true", help="Quiet mode")
|
|
557
|
+
target_parser.add_argument("--no-ansi", action="store_true", help="Disable ANSI colors/banners")
|
|
558
|
+
target_parser.add_argument("--timeout", type=int, default=0, help="Timeout seconds for project commands (test/bootstrap)")
|
|
559
|
+
|
|
560
|
+
_add_global_options(parser)
|
|
561
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
562
|
+
|
|
563
|
+
new_parser = subparsers.add_parser("new", help="Create a new Muscles project")
|
|
564
|
+
new_parser.add_argument("project_name")
|
|
565
|
+
new_parser.add_argument("--runtime", default="asgi", choices=["asgi", "wsgi", "cli", "full"])
|
|
566
|
+
new_parser.add_argument("--force", action="store_true")
|
|
567
|
+
|
|
568
|
+
capabilities_parser = subparsers.add_parser("capabilities", help="Show CLI and project capabilities")
|
|
569
|
+
|
|
570
|
+
generate_parser = subparsers.add_parser("generate", help="Generate Muscles artifacts")
|
|
571
|
+
generate_parser.add_argument("generator_type", choices=["page", "api-resource", "cli-command", "value-object", "resource"])
|
|
572
|
+
generate_parser.add_argument("name")
|
|
573
|
+
generate_parser.add_argument("--force", action="store_true")
|
|
574
|
+
generate_parser.add_argument("--with-tests", action="store_true")
|
|
575
|
+
|
|
576
|
+
doctor_parser = subparsers.add_parser("doctor", help="Diagnose Muscles project")
|
|
577
|
+
|
|
578
|
+
test_parser = subparsers.add_parser("test", help="Run canonical Muscles project tests")
|
|
579
|
+
test_parser.add_argument("--doctor", action="store_true", help="Run doctor before tests")
|
|
580
|
+
|
|
581
|
+
inspect_parser = subparsers.add_parser("inspect", help="Inspect Muscles application contract")
|
|
582
|
+
inspect_parser.add_argument("--app", required=False, help="Entrypoint in module:ClassOrObject format")
|
|
583
|
+
|
|
584
|
+
action_parser = subparsers.add_parser("action", help="List, inspect, and run Muscles actions")
|
|
585
|
+
action_subparsers = action_parser.add_subparsers(dest="action_command")
|
|
586
|
+
action_list_parser = action_subparsers.add_parser("list", help="List actions from an application contract")
|
|
587
|
+
action_list_parser.add_argument("--app", required=True, help="Entrypoint in module:ClassOrObject format")
|
|
588
|
+
action_inspect_parser = action_subparsers.add_parser("inspect", help="Inspect one action contract")
|
|
589
|
+
action_inspect_parser.add_argument("action_name")
|
|
590
|
+
action_inspect_parser.add_argument("--app", required=True, help="Entrypoint in module:ClassOrObject format")
|
|
591
|
+
action_run_parser = action_subparsers.add_parser("run", help="Run one action through the CLI transport")
|
|
592
|
+
action_run_parser.add_argument("action_name")
|
|
593
|
+
action_run_parser.add_argument("--app", required=True, help="Entrypoint in module:ClassOrObject format")
|
|
594
|
+
payload_group = action_run_parser.add_mutually_exclusive_group()
|
|
595
|
+
payload_group.add_argument("--payload-json", help="JSON object payload")
|
|
596
|
+
payload_group.add_argument("--payload-file", help="Path to JSON object payload")
|
|
597
|
+
action_run_parser.add_argument("--json-lines", action="store_true", help="Render stream actions as JSON Lines")
|
|
598
|
+
|
|
599
|
+
for command_name in ("actions", "routes", "schemas", "rules", "cli", "sql"):
|
|
600
|
+
command_parser = subparsers.add_parser(command_name, help=f"Inspect {command_name}")
|
|
601
|
+
command_parser.add_argument("--app", required=False, help="Entrypoint in module:ClassOrObject format")
|
|
602
|
+
return parser
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def load_app_from_entrypoint(entrypoint: str):
|
|
606
|
+
if ":" not in entrypoint:
|
|
607
|
+
raise ValueError("Entrypoint must use module:object format")
|
|
608
|
+
module_name, object_name = entrypoint.split(":", 1)
|
|
609
|
+
module = importlib.import_module(module_name)
|
|
610
|
+
target = getattr(module, object_name)
|
|
611
|
+
if isinstance(target, type):
|
|
612
|
+
return target()
|
|
613
|
+
return target
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def build_inspection_payload(app=None, app_entrypoint: str | None = None) -> dict:
|
|
617
|
+
if app is None and app_entrypoint:
|
|
618
|
+
app = load_app_from_entrypoint(app_entrypoint)
|
|
619
|
+
payload = inspect_application(app=app)
|
|
620
|
+
payload.setdefault("performance_contract", {})
|
|
621
|
+
payload["performance_contract"]["di_signature_cached"] = True
|
|
622
|
+
return payload
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _load_action_payload(payload_json: str | None = None, payload_file: str | None = None) -> dict[str, Any]:
|
|
626
|
+
if payload_json and payload_file:
|
|
627
|
+
raise ValueError("Use either --payload-json or --payload-file, not both.")
|
|
628
|
+
raw = "{}"
|
|
629
|
+
if payload_file:
|
|
630
|
+
raw = Path(payload_file).read_text(encoding="utf-8")
|
|
631
|
+
elif payload_json:
|
|
632
|
+
raw = payload_json
|
|
633
|
+
|
|
634
|
+
payload = json.loads(raw)
|
|
635
|
+
if payload is None:
|
|
636
|
+
return {}
|
|
637
|
+
if not isinstance(payload, dict):
|
|
638
|
+
raise ValueError("Action payload must be a JSON object.")
|
|
639
|
+
return payload
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _find_action_contract(inspection_payload: dict[str, Any], action_name: str) -> dict[str, Any]:
|
|
643
|
+
for action in inspection_payload.get("actions", []):
|
|
644
|
+
if isinstance(action, dict) and action.get("name") == action_name:
|
|
645
|
+
return action
|
|
646
|
+
raise ActionNotFound(action_name, f"Action not found: {action_name}")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _map_action_error(exc: Exception) -> tuple[int, dict[str, Any]]:
|
|
650
|
+
if isinstance(exc, (ActionNotFound, ActionValidationError)):
|
|
651
|
+
return EXIT_INVALID_ARGUMENT, {
|
|
652
|
+
"code": exc.code,
|
|
653
|
+
"message": exc.message,
|
|
654
|
+
"action": exc.action_name,
|
|
655
|
+
"data": exc.data,
|
|
656
|
+
"status": exc.status,
|
|
657
|
+
}
|
|
658
|
+
if isinstance(exc, (ActionPermissionDenied, ActionExecutionError)):
|
|
659
|
+
return EXIT_RUNTIME_ERROR, {
|
|
660
|
+
"code": exc.code,
|
|
661
|
+
"message": exc.message,
|
|
662
|
+
"action": exc.action_name,
|
|
663
|
+
"data": exc.data,
|
|
664
|
+
"status": exc.status,
|
|
665
|
+
}
|
|
666
|
+
if isinstance(exc, ActionError):
|
|
667
|
+
return EXIT_RUNTIME_ERROR, {
|
|
668
|
+
"code": exc.code,
|
|
669
|
+
"message": exc.message,
|
|
670
|
+
"action": exc.action_name,
|
|
671
|
+
"data": exc.data,
|
|
672
|
+
"status": exc.status,
|
|
673
|
+
}
|
|
674
|
+
return EXIT_RUNTIME_ERROR, {
|
|
675
|
+
"code": "internal_error",
|
|
676
|
+
"message": str(exc),
|
|
677
|
+
"action": None,
|
|
678
|
+
"data": None,
|
|
679
|
+
"status": 500,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _execute_action(app_entrypoint: str, action_name: str, payload: dict[str, Any]):
|
|
684
|
+
app = load_app_from_entrypoint(app_entrypoint)
|
|
685
|
+
return ActionDispatcher(app).execute(
|
|
686
|
+
action_name,
|
|
687
|
+
payload,
|
|
688
|
+
transport="cli",
|
|
689
|
+
metadata={"projection": "cli"},
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def build_capabilities_payload() -> dict:
|
|
694
|
+
runtime_mode = resolve_runtime_mode().value
|
|
695
|
+
commands = [
|
|
696
|
+
{"name": "capabilities", "syntax": "muscles capabilities --json", "read_only": True, "mutating": False, "dev_only": False, "production_safe": True},
|
|
697
|
+
{"name": "new", "syntax": "muscles new <project-name> --runtime asgi|wsgi|cli", "read_only": False, "mutating": True, "dev_only": True, "production_safe": False},
|
|
698
|
+
{"name": "inspect", "syntax": "muscles inspect --json --app app.application:App", "read_only": True, "mutating": False, "dev_only": False, "production_safe": "redacted"},
|
|
699
|
+
{"name": "action", "syntax": "muscles action list|inspect|run --app app.application:App", "read_only": "depends_on_subcommand", "mutating": "run", "dev_only": False, "production_safe": "depends_on_action"},
|
|
700
|
+
{"name": "doctor", "syntax": "muscles doctor --json", "read_only": True, "mutating": False, "dev_only": True, "production_safe": False},
|
|
701
|
+
{"name": "generate", "syntax": "muscles generate resource <Name> --with-tests", "read_only": False, "mutating": True, "dev_only": True, "production_safe": False},
|
|
702
|
+
{"name": "test", "syntax": "muscles test", "read_only": True, "mutating": False, "dev_only": False, "production_safe": True},
|
|
703
|
+
]
|
|
704
|
+
return {
|
|
705
|
+
"capabilities_contract_version": "1",
|
|
706
|
+
"framework": "Muscles",
|
|
707
|
+
"runtime_mode": runtime_mode,
|
|
708
|
+
"project_detected": (Path.cwd() / "app").exists(),
|
|
709
|
+
"performance_contract": {"di_signature_cached": True},
|
|
710
|
+
"commands": commands,
|
|
711
|
+
"recommended_ai_workflow": [
|
|
712
|
+
"muscles capabilities --json",
|
|
713
|
+
"muscles inspect --json",
|
|
714
|
+
"muscles action list --app app.application:App --json",
|
|
715
|
+
"muscles generate ...",
|
|
716
|
+
"muscles doctor --json",
|
|
717
|
+
"muscles test",
|
|
718
|
+
],
|
|
719
|
+
"ai_docs": [
|
|
720
|
+
"docs/ai/AGENTS.md",
|
|
721
|
+
"docs/ai/cursor-rules.md",
|
|
722
|
+
"docs/ai/copilot-instructions.md",
|
|
723
|
+
],
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def main(argv: list[str] | None = None) -> int:
|
|
728
|
+
parser = _build_parser()
|
|
729
|
+
global_parser = argparse.ArgumentParser(add_help=False)
|
|
730
|
+
|
|
731
|
+
def _add_global_options(target_parser: argparse.ArgumentParser) -> None:
|
|
732
|
+
target_parser.add_argument("--json", action="store_true", help="JSON output when supported")
|
|
733
|
+
target_parser.add_argument("--machine", action="store_true", help="Machine-readable mode (stdout JSON only)")
|
|
734
|
+
target_parser.add_argument("--quiet", action="store_true", help="Quiet mode")
|
|
735
|
+
target_parser.add_argument("--no-ansi", action="store_true", help="Disable ANSI colors/banners")
|
|
736
|
+
target_parser.add_argument("--timeout", type=int, default=0, help="Timeout seconds for project commands (test/bootstrap)")
|
|
737
|
+
|
|
738
|
+
_add_global_options(global_parser)
|
|
739
|
+
args_global, remaining_argv = global_parser.parse_known_args(argv)
|
|
740
|
+
args = parser.parse_args(remaining_argv)
|
|
741
|
+
for field in ("json", "machine", "quiet", "no_ansi", "timeout"):
|
|
742
|
+
setattr(args, field, getattr(args_global, field))
|
|
743
|
+
machine_mode = getattr(args, "machine", False)
|
|
744
|
+
quiet_mode = getattr(args, "quiet", False)
|
|
745
|
+
no_ansi = getattr(args, "no_ansi", False)
|
|
746
|
+
timeout = getattr(args, "timeout", 0)
|
|
747
|
+
suppress_ansi = machine_mode or no_ansi or not sys.stdout.isatty()
|
|
748
|
+
|
|
749
|
+
def _render_text(value: object) -> str:
|
|
750
|
+
payload = str(value)
|
|
751
|
+
if suppress_ansi:
|
|
752
|
+
return _ANSI_ESCAPE.sub("", payload)
|
|
753
|
+
return payload
|
|
754
|
+
|
|
755
|
+
def _emit(data, as_json: bool = False, *, stderr: bool = False):
|
|
756
|
+
stream = sys.stderr if stderr else sys.stdout
|
|
757
|
+
if machine_mode or as_json:
|
|
758
|
+
stream.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
759
|
+
elif not quiet_mode:
|
|
760
|
+
stream.write(_render_text(f"{data}\n"))
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
if args.command == "capabilities":
|
|
764
|
+
payload = build_capabilities_payload()
|
|
765
|
+
as_json = getattr(args, "json", False) or machine_mode
|
|
766
|
+
if as_json:
|
|
767
|
+
_emit(payload, as_json=True)
|
|
768
|
+
return EXIT_SUCCESS
|
|
769
|
+
_emit("Muscles capabilities")
|
|
770
|
+
_emit(f"runtime_mode: {payload['runtime_mode']}")
|
|
771
|
+
_emit("commands:")
|
|
772
|
+
for item in payload["commands"]:
|
|
773
|
+
_emit(f" - {item['name']}: {item['syntax']}")
|
|
774
|
+
return EXIT_SUCCESS
|
|
775
|
+
|
|
776
|
+
if args.command == "action":
|
|
777
|
+
as_json = getattr(args, "json", False) or machine_mode
|
|
778
|
+
try:
|
|
779
|
+
if args.action_command == "list":
|
|
780
|
+
payload = build_inspection_payload(app_entrypoint=args.app)
|
|
781
|
+
data = {"actions": payload.get("actions", [])}
|
|
782
|
+
_emit(data, as_json=as_json)
|
|
783
|
+
return EXIT_SUCCESS
|
|
784
|
+
|
|
785
|
+
if args.action_command == "inspect":
|
|
786
|
+
payload = build_inspection_payload(app_entrypoint=args.app)
|
|
787
|
+
data = {"action": _find_action_contract(payload, args.action_name)}
|
|
788
|
+
_emit(data, as_json=as_json)
|
|
789
|
+
return EXIT_SUCCESS
|
|
790
|
+
|
|
791
|
+
if args.action_command == "run":
|
|
792
|
+
payload = _load_action_payload(args.payload_json, args.payload_file)
|
|
793
|
+
result = _execute_action(args.app, args.action_name, payload)
|
|
794
|
+
if result.is_stream:
|
|
795
|
+
rendered = render_stream_result(
|
|
796
|
+
result.value,
|
|
797
|
+
json_lines=getattr(args, "json_lines", False) or machine_mode,
|
|
798
|
+
)
|
|
799
|
+
return rendered.exit_code
|
|
800
|
+
data = {
|
|
801
|
+
"action": result.action_name,
|
|
802
|
+
"result": result.value,
|
|
803
|
+
"metadata": dict(result.metadata),
|
|
804
|
+
}
|
|
805
|
+
if as_json:
|
|
806
|
+
_emit(data, as_json=True)
|
|
807
|
+
else:
|
|
808
|
+
_emit(result.value)
|
|
809
|
+
return EXIT_SUCCESS
|
|
810
|
+
|
|
811
|
+
raise ValueError("Action subcommand is required: list, inspect, or run.")
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
exit_code, error = _map_action_error(exc)
|
|
814
|
+
if as_json:
|
|
815
|
+
_emit({"status": "error", "error": error}, as_json=True, stderr=True)
|
|
816
|
+
else:
|
|
817
|
+
_emit(f"error: {error['message']}", stderr=True)
|
|
818
|
+
return exit_code
|
|
819
|
+
|
|
820
|
+
if args.command in {"inspect", "actions", "routes", "schemas", "rules", "cli", "sql"}:
|
|
821
|
+
payload = build_inspection_payload(app_entrypoint=getattr(args, "app", None))
|
|
822
|
+
if args.command == "inspect":
|
|
823
|
+
data = payload
|
|
824
|
+
elif args.command == "actions":
|
|
825
|
+
data = {"actions": payload.get("actions", [])}
|
|
826
|
+
elif args.command == "routes":
|
|
827
|
+
data = {"routes": payload.get("routes", [])}
|
|
828
|
+
elif args.command == "schemas":
|
|
829
|
+
data = {"schemas": payload.get("schemas", [])}
|
|
830
|
+
elif args.command == "rules":
|
|
831
|
+
data = {"rules": payload.get("rules", [])}
|
|
832
|
+
elif args.command == "sql":
|
|
833
|
+
data = {"sql": payload.get("sql", [])}
|
|
834
|
+
else:
|
|
835
|
+
data = {"cli": payload.get("cli", payload.get("commands", []))}
|
|
836
|
+
|
|
837
|
+
if getattr(args, "json", False) or machine_mode:
|
|
838
|
+
_emit(data, as_json=True)
|
|
839
|
+
else:
|
|
840
|
+
_emit(data)
|
|
841
|
+
return EXIT_SUCCESS
|
|
842
|
+
|
|
843
|
+
if args.command == "generate":
|
|
844
|
+
generated = generate_artifact(
|
|
845
|
+
project_root=Path.cwd(),
|
|
846
|
+
generator_type=args.generator_type,
|
|
847
|
+
name=args.name,
|
|
848
|
+
force=args.force,
|
|
849
|
+
with_tests=args.with_tests,
|
|
850
|
+
)
|
|
851
|
+
if machine_mode:
|
|
852
|
+
_emit({"status": "ok", "generated": generated}, as_json=True)
|
|
853
|
+
return EXIT_SUCCESS
|
|
854
|
+
_emit("Generated files:")
|
|
855
|
+
for item in generated:
|
|
856
|
+
_emit(f" - {item}")
|
|
857
|
+
_emit("Next steps:")
|
|
858
|
+
_emit(" muscles inspect --json")
|
|
859
|
+
_emit(" muscles test")
|
|
860
|
+
return EXIT_SUCCESS
|
|
861
|
+
|
|
862
|
+
if args.command == "doctor":
|
|
863
|
+
root = detect_project_root()
|
|
864
|
+
report = run_doctor(root)
|
|
865
|
+
if getattr(args, "json", False) or machine_mode:
|
|
866
|
+
_emit(report, as_json=True)
|
|
867
|
+
else:
|
|
868
|
+
_emit(report)
|
|
869
|
+
return EXIT_SUCCESS if len(report["errors"]) == 0 else EXIT_INVALID_ARGUMENT
|
|
870
|
+
|
|
871
|
+
if args.command == "test":
|
|
872
|
+
root = detect_project_root()
|
|
873
|
+
if getattr(args, "doctor", False):
|
|
874
|
+
report = run_doctor(root)
|
|
875
|
+
if len(report["errors"]) > 0:
|
|
876
|
+
if machine_mode:
|
|
877
|
+
_emit({"status": "error", "report": report}, as_json=True, stderr=True)
|
|
878
|
+
else:
|
|
879
|
+
_emit("Doctor failed. Fix errors before test run.")
|
|
880
|
+
_emit(report, as_json=True)
|
|
881
|
+
return EXIT_INVALID_ARGUMENT
|
|
882
|
+
return run_project_tests(root, timeout=timeout)
|
|
883
|
+
|
|
884
|
+
if args.command != "new":
|
|
885
|
+
help_payload = {
|
|
886
|
+
"framework": "Muscles",
|
|
887
|
+
"commands": ["new", "capabilities"],
|
|
888
|
+
"status": "ok",
|
|
889
|
+
}
|
|
890
|
+
if getattr(args, "json", False) or machine_mode:
|
|
891
|
+
_emit(help_payload, as_json=True)
|
|
892
|
+
return EXIT_SUCCESS
|
|
893
|
+
parser.print_help()
|
|
894
|
+
return EXIT_INVALID_ARGUMENT
|
|
895
|
+
|
|
896
|
+
target = Path.cwd() / args.project_name
|
|
897
|
+
files = scaffold_project(target=target, runtime=args.runtime, force=args.force)
|
|
898
|
+
if machine_mode:
|
|
899
|
+
_emit({"status": "ok", "target": str(target), "generated": files}, as_json=True)
|
|
900
|
+
return EXIT_SUCCESS
|
|
901
|
+
_emit(f"Created project at: {target}")
|
|
902
|
+
_emit("Generated files:")
|
|
903
|
+
for file_name in files:
|
|
904
|
+
_emit(f" - {file_name}")
|
|
905
|
+
_emit("Next steps:")
|
|
906
|
+
_emit(f" cd {args.project_name}")
|
|
907
|
+
_emit(" PYTHONPATH=. python -m pytest -q")
|
|
908
|
+
return EXIT_SUCCESS
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
if machine_mode or getattr(args, "json", False):
|
|
911
|
+
_emit(
|
|
912
|
+
{
|
|
913
|
+
"status": "error",
|
|
914
|
+
"error": {
|
|
915
|
+
"type": exc.__class__.__name__,
|
|
916
|
+
"message": str(exc),
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
as_json=True,
|
|
920
|
+
stderr=True,
|
|
921
|
+
)
|
|
922
|
+
return EXIT_INVALID_ARGUMENT
|
|
923
|
+
return EXIT_RUNTIME_ERROR
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
if __name__ == "__main__":
|
|
927
|
+
raise SystemExit(main())
|