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/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())