rpr-cli 0.1.1__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.
Files changed (97) hide show
  1. rpr/__init__.py +1 -0
  2. rpr/agent/__init__.py +29 -0
  3. rpr/agent/approval.py +19 -0
  4. rpr/agent/bootstrap.py +60 -0
  5. rpr/agent/client.py +115 -0
  6. rpr/agent/control.py +16 -0
  7. rpr/agent/mock.py +158 -0
  8. rpr/agent/runtime.py +291 -0
  9. rpr/agent/session.py +91 -0
  10. rpr/agent/tools/__init__.py +10 -0
  11. rpr/agent/tools/base.py +62 -0
  12. rpr/agent/tools/mutating.py +47 -0
  13. rpr/agent/tools/readonly.py +147 -0
  14. rpr/agent/tools/registry.py +30 -0
  15. rpr/application/__init__.py +1 -0
  16. rpr/application/catalog.py +427 -0
  17. rpr/application/chat_service.py +413 -0
  18. rpr/application/checks.py +49 -0
  19. rpr/application/cli_adapter.py +36 -0
  20. rpr/application/completer.py +163 -0
  21. rpr/application/conversation_service.py +92 -0
  22. rpr/application/prompt_service.py +85 -0
  23. rpr/application/selector.py +240 -0
  24. rpr/application/shell.py +543 -0
  25. rpr/checks/__init__.py +0 -0
  26. rpr/checks/base.py +13 -0
  27. rpr/checks/instructions.py +89 -0
  28. rpr/checks/packages.py +619 -0
  29. rpr/checks/workspace.py +170 -0
  30. rpr/cli.py +78 -0
  31. rpr/commands/__init__.py +0 -0
  32. rpr/commands/add.py +166 -0
  33. rpr/commands/chat.py +48 -0
  34. rpr/commands/check.py +53 -0
  35. rpr/commands/generate/__init__.py +0 -0
  36. rpr/commands/generate/api.py +228 -0
  37. rpr/commands/generate/domain.py +383 -0
  38. rpr/commands/generate/engine.py +148 -0
  39. rpr/commands/generate/storybook.py +442 -0
  40. rpr/commands/generate/ui.py +414 -0
  41. rpr/commands/init.py +822 -0
  42. rpr/commands/map.py +113 -0
  43. rpr/commands/settings.py +102 -0
  44. rpr/commands/sync.py +97 -0
  45. rpr/context.py +203 -0
  46. rpr/generators/__init__.py +0 -0
  47. rpr/generators/base.py +110 -0
  48. rpr/generators/claude.py +33 -0
  49. rpr/generators/copilot.py +36 -0
  50. rpr/generators/cursor.py +38 -0
  51. rpr/generators/gemini.py +33 -0
  52. rpr/map/__init__.py +0 -0
  53. rpr/map/architecture.py +495 -0
  54. rpr/map/chains.py +317 -0
  55. rpr/map/classifier.py +170 -0
  56. rpr/map/coverage.py +200 -0
  57. rpr/map/dependencies.py +243 -0
  58. rpr/map/extractor.py +223 -0
  59. rpr/map/graph.py +318 -0
  60. rpr/map/output.py +1030 -0
  61. rpr/map/responsibility.py +345 -0
  62. rpr/map/topology.py +327 -0
  63. rpr/map/walker.py +151 -0
  64. rpr/scaffolds/domain/base_entity.md +30 -0
  65. rpr/scaffolds/domain/base_repo.md +48 -0
  66. rpr/scaffolds/domain/container.md +76 -0
  67. rpr/scaffolds/domain/settings.md +57 -0
  68. rpr/scaffolds/instructions/all.instructions.md +50 -0
  69. rpr/scaffolds/instructions/api.instructions.md +42 -0
  70. rpr/scaffolds/instructions/domain.instructions.md +93 -0
  71. rpr/scaffolds/instructions/frontend.instructions.md +97 -0
  72. rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
  73. rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
  74. rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
  75. rpr/scaffolds/instructions/tooling.instructions.md +42 -0
  76. rpr/scaffolds/js_special_files/fetch.service.md +222 -0
  77. rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
  78. rpr/scaffolds/special_files/domain_container.md +76 -0
  79. rpr/scaffolds/special_files/domain_settings.md +57 -0
  80. rpr/scaffolds/special_files/dto_util.md +62 -0
  81. rpr/scaffolds/special_files/encrypted_column.md +98 -0
  82. rpr/scaffolds/special_files/mapper_util.md +159 -0
  83. rpr/scaffolds/special_files/partial_update.md +61 -0
  84. rpr/templates/__init__.py +0 -0
  85. rpr/templates/registry.py +81 -0
  86. rpr/ui/__init__.py +0 -0
  87. rpr/ui/console.py +32 -0
  88. rpr/ui/markdown.py +59 -0
  89. rpr/ui/prompt_session.py +430 -0
  90. rpr/ui/renderers.py +167 -0
  91. rpr/ui/theme.py +286 -0
  92. rpr/workspace.py +131 -0
  93. rpr_cli-0.1.1.dist-info/METADATA +201 -0
  94. rpr_cli-0.1.1.dist-info/RECORD +97 -0
  95. rpr_cli-0.1.1.dist-info/WHEEL +4 -0
  96. rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
  97. rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
rpr/commands/init.py ADDED
@@ -0,0 +1,822 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import chdir
4
+ import json
5
+ import re
6
+ import subprocess
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Annotated, Literal
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from rpr.cli import app
15
+ from rpr.context import RunContext
16
+ from rpr.generators.base import SyncConfig, load_rules
17
+ from rpr.generators.claude import generate_claude_rules
18
+ from rpr.generators.copilot import generate_copilot_rules
19
+ from rpr.generators.cursor import generate_cursor_rules
20
+ from rpr.generators.gemini import generate_gemini_rules
21
+ from rpr.workspace import ensure_npm_workspaces
22
+
23
+ console = Console()
24
+
25
+ NxState = Literal["new", "existing_non_nx", "already_nx"]
26
+ ScaffoldName = Literal["instructions", "domain", "api", "ui", "web", "storybook", "engine"]
27
+
28
+ _ALL_TARGETS = ["cursor", "copilot", "claude", "gemini"]
29
+ _TARGET_GENERATORS = {
30
+ "cursor": generate_cursor_rules,
31
+ "copilot": generate_copilot_rules,
32
+ "claude": generate_claude_rules,
33
+ "gemini": generate_gemini_rules,
34
+ }
35
+ _NX_BOOTSTRAP_ENV = {"NX_NO_CLOUD": "true"}
36
+ _ROOT_NODE_DEV_DEPENDENCIES = {
37
+ "@eslint/js": "latest",
38
+ "@nx/eslint": "latest",
39
+ "@nx/eslint-plugin": "latest",
40
+ "eslint": "latest",
41
+ "eslint-plugin-import": "latest",
42
+ "eslint-plugin-jsx-a11y": "latest",
43
+ "eslint-plugin-react": "latest",
44
+ "eslint-plugin-react-hooks": "latest",
45
+ "globals": "latest",
46
+ "prettier": "latest",
47
+ "typescript-eslint": "latest",
48
+ }
49
+ _ROOT_NODE_SCRIPTS = {
50
+ "format": "npx prettier --write .",
51
+ "format:check": "npx prettier --check .",
52
+ "lint": "npx nx run-many --target=lint --all",
53
+ "lint:fix": "npx nx run-many --target=lint --all -- --fix",
54
+ "typecheck": "npx nx run-many --target=typecheck --all",
55
+ }
56
+ _SCAFFOLD_DEPENDENCIES: dict[ScaffoldName, tuple[ScaffoldName, ...]] = {
57
+ "instructions": (),
58
+ "domain": (),
59
+ "api": ("domain",),
60
+ "ui": (),
61
+ "web": ("ui",),
62
+ "storybook": ("ui",),
63
+ "engine": (),
64
+ }
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Detection helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _detect_nx_state(project_dir: Path) -> NxState:
73
+ """Classify the target directory as new, existing-non-NX, or already-NX."""
74
+ if not project_dir.exists() or not any(project_dir.iterdir()):
75
+ return "new"
76
+ if (project_dir / "nx.json").exists() or (project_dir / "project.json").exists():
77
+ return "already_nx"
78
+ return "existing_non_nx"
79
+
80
+
81
+ def _detect_uv_state(project_dir: Path) -> bool:
82
+ """Return True when the directory already has a UV-managed pyproject.toml."""
83
+ pyproject = project_dir / "pyproject.toml"
84
+ if not pyproject.exists():
85
+ return False
86
+ try:
87
+ data = tomllib.loads(pyproject.read_text())
88
+ except tomllib.TOMLDecodeError:
89
+ return False
90
+ tool = data.get("tool", {})
91
+ return "uv" in tool
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Variable derivation
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _derive_variables(project_name: str) -> dict[str, str]:
100
+ """Derive the four canonical token variants from the raw project name."""
101
+ name_underscore = project_name.replace("-", "_")
102
+ pascal = "".join(w.capitalize() for w in re.split(r"[-_]+", project_name) if w)
103
+ return {
104
+ "name": project_name,
105
+ "name-of-app": project_name,
106
+ "name_underscore": name_underscore,
107
+ "NamePascal": pascal,
108
+ }
109
+
110
+
111
+ def _normalize_scaffold_name(value: str) -> ScaffoldName:
112
+ normalized = value.strip().lower()
113
+ if normalized not in _SCAFFOLD_DEPENDENCIES:
114
+ allowed = ", ".join(_SCAFFOLD_DEPENDENCIES)
115
+ raise typer.BadParameter(
116
+ f"Unknown scaffold '{value}'. Choose from: {allowed}."
117
+ )
118
+ return normalized # type: ignore[return-value]
119
+
120
+
121
+ def _resolve_scaffolds(scaffolds: list[ScaffoldName] | None) -> list[ScaffoldName]:
122
+ requested = scaffolds or ["instructions"]
123
+ resolved: list[ScaffoldName] = []
124
+ seen: set[ScaffoldName] = set()
125
+
126
+ def visit(name: ScaffoldName) -> None:
127
+ if name in seen:
128
+ return
129
+ seen.add(name)
130
+ for dependency in _SCAFFOLD_DEPENDENCIES[name]:
131
+ visit(dependency)
132
+ resolved.append(name)
133
+
134
+ for scaffold_name in requested:
135
+ visit(scaffold_name)
136
+
137
+ return resolved
138
+
139
+
140
+ def _run_requested_scaffolds(
141
+ project_name: str,
142
+ project_dir: Path,
143
+ dry_run: bool,
144
+ scaffolds: list[ScaffoldName],
145
+ domain_migrations: bool,
146
+ ) -> None:
147
+ from rpr.commands.generate.api import api as generate_api
148
+ from rpr.commands.generate.domain import domain as generate_domain
149
+ from rpr.commands.generate.engine import engine as generate_engine
150
+ from rpr.commands.generate.storybook import storybook as generate_storybook
151
+ from rpr.commands.generate.ui import ui as generate_ui
152
+
153
+ include_library = "ui" in scaffolds
154
+ include_web = "web" in scaffolds
155
+
156
+ if not scaffolds:
157
+ return
158
+
159
+ console.print(
160
+ "\n[bold]Scaffolding requested project components[/]"
161
+ f" ({', '.join(scaffolds)})\n"
162
+ )
163
+ with chdir(project_dir):
164
+ scaffold_generators = {
165
+ "domain": lambda: generate_domain(
166
+ name=project_name,
167
+ dry_run=dry_run,
168
+ migrations=domain_migrations,
169
+ ),
170
+ "api": lambda: generate_api(name=project_name, dry_run=dry_run),
171
+ "storybook": lambda: generate_storybook(dry_run=dry_run),
172
+ "engine": lambda: generate_engine(name=project_name, dry_run=dry_run),
173
+ }
174
+ ui_handled = False
175
+ for scaffold in scaffolds:
176
+ if scaffold == "instructions":
177
+ continue
178
+ if scaffold in {"ui", "web"}:
179
+ if ui_handled:
180
+ continue
181
+ generate_ui(
182
+ name=project_name,
183
+ dry_run=dry_run,
184
+ include_library=include_library,
185
+ include_web=include_web,
186
+ )
187
+ ui_handled = True
188
+ continue
189
+ scaffold_generators[scaffold]()
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Package-bundled instruction templates
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def _bundled_instructions_dir() -> Path:
198
+ """Absolute path to the instruction templates bundled with the rpr package."""
199
+ # src/rpr/commands/init.py → parent = commands/ → parent = rpr/
200
+ return Path(__file__).resolve().parent.parent / "scaffolds" / "instructions"
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # AI instruction scaffolding
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ def _scaffold_ai_instructions(
209
+ ctx: RunContext,
210
+ project_dir: Path,
211
+ variables: dict[str, str],
212
+ ) -> None:
213
+ instructions_dir = _bundled_instructions_dir()
214
+ if not instructions_dir.exists():
215
+ console.print(
216
+ f"[bold yellow]Warning:[/] Bundled instructions not found at {instructions_dir}. "
217
+ "Skipping AI instruction scaffolding."
218
+ )
219
+ return
220
+
221
+ rules = load_rules(instructions_dir)
222
+ if not rules:
223
+ console.print(
224
+ "[bold yellow]Warning:[/] No instruction rules found. Skipping scaffolding."
225
+ )
226
+ return
227
+
228
+ console.print(f"\n[bold]Scaffolding AI instructions[/] ({len(rules)} rules)\n")
229
+ for target in _ALL_TARGETS:
230
+ console.print(f" [bold]→[/] {target}")
231
+ _TARGET_GENERATORS[target](rules, project_dir, variables, ctx)
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # .rpr.yaml
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def _write_rpr_yaml(
240
+ ctx: RunContext,
241
+ project_dir: Path,
242
+ variables: dict[str, str],
243
+ ) -> None:
244
+ instructions_dir = _bundled_instructions_dir()
245
+ config = SyncConfig(
246
+ source=str(instructions_dir),
247
+ targets=list(_ALL_TARGETS),
248
+ include=[],
249
+ exclude=[],
250
+ variables=variables,
251
+ )
252
+ config_path = project_dir / ".rpr.yaml"
253
+
254
+ if ctx.dry_run:
255
+ import yaml
256
+
257
+ console.print(f"\n[bold yellow][DRY RUN][/] Would write: {config_path}")
258
+ preview = yaml.dump(
259
+ {
260
+ "source": config.source,
261
+ "targets": config.targets,
262
+ "include": config.include,
263
+ "exclude": config.exclude,
264
+ "variables": config.variables,
265
+ },
266
+ default_flow_style=False,
267
+ sort_keys=False,
268
+ )
269
+ console.print(f"[dim]{'─' * 60}[/]")
270
+ for i, line in enumerate(preview.splitlines(), 1):
271
+ console.print(f" [dim]{i:>4}[/] │ {line}")
272
+ console.print(f"[dim]{'─' * 60}[/]")
273
+ return
274
+
275
+ config.save(config_path)
276
+ console.print(f"[bold green]Created:[/] {config_path}")
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Root config files
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def _write_nx_json(ctx: RunContext) -> None:
285
+ content = """{
286
+ "$schema": "./node_modules/nx/schemas/nx-schema.json",
287
+ "neverConnectToCloud": true,
288
+ "plugins": [
289
+ {
290
+ "plugin": "@nx/eslint/plugin",
291
+ "options": {
292
+ "targetName": "lint"
293
+ }
294
+ }
295
+ ],
296
+ "targetDefaults": {
297
+ "build": {
298
+ "dependsOn": ["^build"],
299
+ "cache": true
300
+ },
301
+ "format": {
302
+ "cache": true
303
+ },
304
+ "test": {
305
+ "cache": true
306
+ },
307
+ "lint": {
308
+ "cache": true
309
+ },
310
+ "typecheck": {
311
+ "cache": true
312
+ }
313
+ },
314
+ "defaultBase": "main"
315
+ }
316
+ """
317
+ ctx.write_file(ctx.project_dir / "nx.json", content)
318
+
319
+
320
+ def _write_root_pyproject(
321
+ ctx: RunContext, project_name: str, python_version: str
322
+ ) -> None:
323
+ uv_require_version = _detect_uv_require_version()
324
+ content = f"""[project]
325
+ name = "{project_name}"
326
+ version = "0.1.0"
327
+ description = "A monorepo combining NX and UV"
328
+ readme = "README.md"
329
+ requires-python = ">={python_version}"
330
+ dependencies = []
331
+
332
+ [tool.uv]
333
+ require-version = "{uv_require_version}"
334
+
335
+ [tool.uv.workspace]
336
+ members = []
337
+
338
+ [dependency-groups]
339
+ dev = [
340
+ "pytest",
341
+ "pytest-asyncio",
342
+ "pytest-cov",
343
+ "ipykernel",
344
+ "pre-commit",
345
+ "pyright",
346
+ "ruff",
347
+ "maturin",
348
+ ]
349
+
350
+ [tool.ruff]
351
+ line-length = 88
352
+ target-version = "py{python_version.replace(".", "")}"
353
+ exclude = ["**/migrations/**"]
354
+
355
+ [tool.ruff.lint]
356
+ select = ["E", "F", "W", "I001"]
357
+ ignore = ["E501", "B008", "C901"]
358
+
359
+ [tool.ruff.lint.per-file-ignores]
360
+ "__init__.py" = ["F401"]
361
+ "tests/**/*.py" = ["F841"]
362
+
363
+ [tool.ruff.format]
364
+ quote-style = "double"
365
+ indent-style = "space"
366
+ line-ending = "lf"
367
+ skip-magic-trailing-comma = false
368
+
369
+ [tool.pytest.ini_options]
370
+ pythonpath = []
371
+ testpaths = []
372
+ """
373
+ ctx.update_file(ctx.project_dir / "pyproject.toml", content)
374
+
375
+
376
+ def _detect_uv_require_version() -> str:
377
+ try:
378
+ result = subprocess.run(
379
+ ["uv", "--version"],
380
+ capture_output=True,
381
+ text=True,
382
+ check=True,
383
+ )
384
+ except (OSError, subprocess.CalledProcessError):
385
+ return ">=0"
386
+
387
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout)
388
+ if not match:
389
+ return ">=0"
390
+ return f">={match.group(1)}"
391
+
392
+
393
+ def _write_root_package_json(ctx: RunContext, project_name: str) -> None:
394
+ ensure_npm_workspaces(ctx, package_name=project_name)
395
+
396
+ package_path = ctx.project_dir / "package.json"
397
+ package = json.loads(package_path.read_text())
398
+ package.setdefault("name", project_name)
399
+ package["private"] = True
400
+
401
+ scripts = package.setdefault("scripts", {})
402
+ if not isinstance(scripts, dict):
403
+ scripts = {}
404
+ package["scripts"] = scripts
405
+ scripts.update(_ROOT_NODE_SCRIPTS)
406
+
407
+ dev_dependencies = package.setdefault("devDependencies", {})
408
+ if not isinstance(dev_dependencies, dict):
409
+ dev_dependencies = {}
410
+ package["devDependencies"] = dev_dependencies
411
+
412
+ for name, version in _ROOT_NODE_DEV_DEPENDENCIES.items():
413
+ dev_dependencies.setdefault(name, version)
414
+
415
+ ctx.update_file(package_path, json.dumps(package, indent=2) + "\n")
416
+
417
+
418
+ def _write_root_eslint_config(ctx: RunContext) -> None:
419
+ content = """import js from \"@eslint/js\";
420
+ import nxPlugin from \"@nx/eslint-plugin\";
421
+ import globals from \"globals\";
422
+ import importPlugin from \"eslint-plugin-import\";
423
+ import jsxA11yPlugin from \"eslint-plugin-jsx-a11y\";
424
+ import reactPlugin from \"eslint-plugin-react\";
425
+ import reactHooksPlugin from \"eslint-plugin-react-hooks\";
426
+ import tseslint from \"typescript-eslint\";
427
+
428
+ export default [
429
+ {
430
+ ignores: [\"build/**\", \"coverage/**\", \"dist/**\", \"node_modules/**\", ".nx/**"],
431
+ },
432
+ js.configs.recommended,
433
+ ...tseslint.configs.recommended,
434
+ {
435
+ files: [\"**/*.{js,jsx,ts,tsx}\"],
436
+ plugins: {
437
+ \"@nx\": nxPlugin,
438
+ import: importPlugin,
439
+ \"jsx-a11y\": jsxA11yPlugin,
440
+ react: reactPlugin,
441
+ \"react-hooks\": reactHooksPlugin,
442
+ },
443
+ languageOptions: {
444
+ parser: tseslint.parser,
445
+ parserOptions: {
446
+ ecmaFeatures: {
447
+ jsx: true,
448
+ },
449
+ ecmaVersion: \"latest\",
450
+ sourceType: \"module\",
451
+ },
452
+ globals: {
453
+ ...globals.browser,
454
+ ...globals.node,
455
+ },
456
+ },
457
+ settings: {
458
+ react: {
459
+ version: \"detect\",
460
+ },
461
+ },
462
+ rules: {
463
+ \"@nx/enforce-module-boundaries\": [
464
+ \"error\",
465
+ {
466
+ enforceBuildableLibDependency: true,
467
+ allow: [],
468
+ depConstraints: [
469
+ {
470
+ sourceTag: \"*\",
471
+ onlyDependOnLibsWithTags: [\"*\"],
472
+ },
473
+ ],
474
+ },
475
+ ],
476
+ \"react-hooks/exhaustive-deps\": \"warn\",
477
+ \"react-hooks/rules-of-hooks\": \"error\",
478
+ \"react/jsx-uses-react\": \"off\",
479
+ \"react/react-in-jsx-scope\": \"off\",
480
+ },
481
+ },
482
+ ];
483
+ """
484
+ ctx.write_file(ctx.project_dir / "eslint.config.mjs", content)
485
+
486
+
487
+ def _write_prettier_config(ctx: RunContext) -> None:
488
+ content = """{
489
+ \"singleQuote\": true,
490
+ \"printWidth\": 100,
491
+ \"semi\": true,
492
+ \"tabWidth\": 2,
493
+ \"trailingComma\": \"es5\"
494
+ }
495
+ """
496
+ ctx.write_file(ctx.project_dir / ".prettierrc", content)
497
+
498
+
499
+ def _write_prettierignore(ctx: RunContext) -> None:
500
+ content = """build/
501
+ coverage/
502
+ dist/
503
+ node_modules/
504
+ .nx/
505
+ """
506
+ ctx.write_file(ctx.project_dir / ".prettierignore", content)
507
+
508
+
509
+ def _write_pre_commit_config(ctx: RunContext) -> None:
510
+ content = """repos:
511
+ - repo: https://github.com/pre-commit/pre-commit-hooks
512
+ rev: v6.0.0
513
+ hooks:
514
+ - id: check-merge-conflict
515
+ - id: end-of-file-fixer
516
+ - id: trailing-whitespace
517
+ args: [--markdown-linebreak-ext=md]
518
+ - id: fix-byte-order-marker
519
+ - id: mixed-line-ending
520
+ args: [--fix=lf]
521
+ exclude: \\.ipynb$
522
+ - id: detect-private-key
523
+
524
+ - repo: https://github.com/kynan/nbstripout
525
+ rev: v0.8.2
526
+ hooks:
527
+ - id: nbstripout
528
+
529
+ - repo: local
530
+ hooks:
531
+ - id: ruff-format
532
+ name: Ruff format
533
+ entry: uv run ruff format
534
+ language: system
535
+ types: [python]
536
+ pass_filenames: false
537
+
538
+ - id: ruff-lint
539
+ name: Ruff lint
540
+ entry: uv run ruff check --fix
541
+ language: system
542
+ types: [python]
543
+ pass_filenames: false
544
+
545
+ - id: prettier
546
+ name: Prettier format
547
+ entry: npx prettier --write --log-level=warn
548
+ language: system
549
+ types_or: [javascript, jsx, ts, tsx, json, css, scss, html, markdown]
550
+ exclude: \\.ipynb$
551
+ pass_filenames: true
552
+
553
+ - id: eslint
554
+ name: ESLint (NX)
555
+ entry: npx nx run-many --target=lint --all --fix --exclude=engine
556
+ language: system
557
+ types_or: [javascript, jsx, ts, tsx]
558
+ pass_filenames: false
559
+ """
560
+ ctx.write_file(ctx.project_dir / ".pre-commit-config.yaml", content)
561
+
562
+
563
+ def _write_tsconfig_base(ctx: RunContext) -> None:
564
+ content = """{
565
+ "compilerOptions": {
566
+ "target": "ES2022",
567
+ "module": "ESNext",
568
+ "moduleResolution": "bundler",
569
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
570
+ "jsx": "react-jsx",
571
+ "strict": true,
572
+ "esModuleInterop": true,
573
+ "skipLibCheck": true,
574
+ "forceConsistentCasingInFileNames": true,
575
+ "resolveJsonModule": true,
576
+ "isolatedModules": true,
577
+ "noEmit": true,
578
+ "declaration": true,
579
+ "declarationMap": true,
580
+ "sourceMap": true,
581
+ "baseUrl": ".",
582
+ "paths": {}
583
+ },
584
+ "exclude": ["node_modules", "dist", "build"]
585
+ }
586
+ """
587
+ ctx.write_file(ctx.project_dir / "tsconfig.base.json", content)
588
+
589
+
590
+ def _write_gitignore(ctx: RunContext) -> None:
591
+ content = """# Node
592
+ node_modules/
593
+ dist/
594
+ build/
595
+ .next/
596
+ *.tsbuildinfo
597
+
598
+ # Python
599
+ __pycache__/
600
+ *.py[cod]
601
+ *.egg-info/
602
+ .venv/
603
+ *.egg
604
+
605
+ # Rust
606
+ target/
607
+
608
+ # Environment
609
+ .env
610
+ .env.local
611
+ .env.*.local
612
+
613
+ # IDE
614
+ .idea/
615
+ .vscode/
616
+ *.swp
617
+ *.swo
618
+
619
+ # OS
620
+ .DS_Store
621
+ Thumbs.db
622
+
623
+ # NX
624
+ .nx/
625
+
626
+ # UV
627
+ uv.lock
628
+ """
629
+ ctx.write_file(ctx.project_dir / ".gitignore", content)
630
+
631
+
632
+ def _write_env_example(ctx: RunContext) -> None:
633
+ content = """# Database
634
+ DATABASE_URL=
635
+
636
+ # Auth
637
+ OAUTH_CLIENT_ID=
638
+ OAUTH_CLIENT_SECRET=
639
+
640
+ # Encryption
641
+ FERNET_KEY=
642
+
643
+ # API
644
+ API_BASE_URL=http://localhost:8000
645
+ """
646
+ ctx.write_file(ctx.project_dir / ".env.example", content)
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # Command
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ @app.command()
655
+ def init(
656
+ project_name: Annotated[
657
+ str | None, typer.Argument(help="Name of the project to create.")
658
+ ] = None,
659
+ dry_run: Annotated[
660
+ bool, typer.Option("--dry-run", help="Preview changes without writing.")
661
+ ] = False,
662
+ python_version: Annotated[
663
+ str, typer.Option("--python", help="Python version to use.")
664
+ ] = "3.14",
665
+ integrated: Annotated[
666
+ bool, typer.Option("--integrated", help="Use NX integrated monorepo layout.")
667
+ ] = False,
668
+ scaffold: Annotated[
669
+ list[str] | None,
670
+ typer.Option(
671
+ "--scaffold",
672
+ help=(
673
+ "Repeat to scaffold additional components: instructions, domain, "
674
+ "api, ui, web, storybook, engine. Defaults to instructions only."
675
+ ),
676
+ ),
677
+ ] = None,
678
+ domain_migrations: Annotated[
679
+ bool,
680
+ typer.Option(
681
+ "--domain-migrations",
682
+ help=(
683
+ "Scaffold Alembic migrations when the selected scaffolds include "
684
+ "the domain package, directly or through a dependency such as api."
685
+ ),
686
+ ),
687
+ ] = False,
688
+ ) -> None:
689
+ """Scaffold a complete NX + UV monorepo with AI instructions."""
690
+ if not project_name:
691
+ project_name = typer.prompt("Project name")
692
+
693
+ selected_scaffolds = _resolve_scaffolds(
694
+ [_normalize_scaffold_name(value) for value in scaffold or []]
695
+ )
696
+ if domain_migrations and "domain" not in selected_scaffolds:
697
+ raise typer.BadParameter(
698
+ "--domain-migrations requires a scaffold that generates the domain "
699
+ "package, such as --scaffold domain or --scaffold api."
700
+ )
701
+ variables = _derive_variables(project_name)
702
+ project_dir = Path.cwd() / project_name
703
+ ctx = RunContext(dry_run=dry_run, project_dir=project_dir)
704
+
705
+ console.print(f"\n[bold]Initializing RPR monorepo:[/] {project_name}\n")
706
+
707
+ # ------------------------------------------------------------------
708
+ # Step 1 – NX setup
709
+ # ------------------------------------------------------------------
710
+ nx_state = _detect_nx_state(project_dir)
711
+
712
+ if nx_state == "already_nx":
713
+ console.print("[bold yellow]Already an NX project.[/] Skipping NX setup.")
714
+ elif nx_state == "new":
715
+ nx_args = [
716
+ f"--name={project_name}",
717
+ "--preset=apps",
718
+ "--nxCloud=never",
719
+ "--interactive=false",
720
+ ]
721
+ if integrated:
722
+ nx_args.append("--integrated")
723
+ ctx.run_command(
724
+ ["npx", "--yes", "create-nx-workspace@latest"] + nx_args,
725
+ cwd=Path.cwd(),
726
+ env=_NX_BOOTSTRAP_ENV,
727
+ )
728
+ else: # existing_non_nx
729
+ if not dry_run:
730
+ console.print(
731
+ f"[bold yellow]Warning:[/] Directory {project_dir} already exists."
732
+ )
733
+ if not typer.confirm("Continue with initialization in existing directory?"):
734
+ raise typer.Exit(0)
735
+ ctx.run_command(["git", "init"], cwd=project_dir)
736
+ nx_cmd = [
737
+ "npx",
738
+ "--yes",
739
+ "nx",
740
+ "init",
741
+ "--interactive=false",
742
+ "--nxCloud=false",
743
+ ]
744
+ if integrated:
745
+ nx_cmd.append("--integrated")
746
+ ctx.run_command(nx_cmd, cwd=project_dir, env=_NX_BOOTSTRAP_ENV)
747
+
748
+ # ------------------------------------------------------------------
749
+ # Step 2 – UV setup
750
+ # ------------------------------------------------------------------
751
+ if _detect_uv_state(project_dir):
752
+ console.print("[bold yellow]Already a UV project.[/] Skipping uv init.")
753
+ else:
754
+ ctx.run_command(
755
+ [
756
+ "uv",
757
+ "init",
758
+ "--no-readme",
759
+ "--no-pin-python",
760
+ "--python",
761
+ python_version,
762
+ ],
763
+ cwd=project_dir,
764
+ )
765
+
766
+ # ------------------------------------------------------------------
767
+ # Step 3 – Standard directories
768
+ # ------------------------------------------------------------------
769
+ ctx.ensure_dir(project_dir / "apps")
770
+ ctx.ensure_dir(project_dir / "packages" / "node")
771
+ ctx.ensure_dir(project_dir / "packages" / "python")
772
+
773
+ # ------------------------------------------------------------------
774
+ # Step 4 – Root config files
775
+ # ------------------------------------------------------------------
776
+ _write_nx_json(ctx)
777
+ _write_root_pyproject(ctx, project_name, python_version)
778
+ _write_root_package_json(ctx, project_name)
779
+ _write_tsconfig_base(ctx)
780
+ _write_root_eslint_config(ctx)
781
+ _write_prettier_config(ctx)
782
+ _write_prettierignore(ctx)
783
+ _write_pre_commit_config(ctx)
784
+ _write_gitignore(ctx)
785
+ _write_env_example(ctx)
786
+
787
+ # ------------------------------------------------------------------
788
+ # Step 5 – AI instruction files
789
+ # ------------------------------------------------------------------
790
+ if "instructions" in selected_scaffolds:
791
+ _scaffold_ai_instructions(ctx, project_dir, variables)
792
+
793
+ # ------------------------------------------------------------------
794
+ # Step 6 – .rpr.yaml (source path + variables block)
795
+ # ------------------------------------------------------------------
796
+ _write_rpr_yaml(ctx, project_dir, variables)
797
+
798
+ # ------------------------------------------------------------------
799
+ # Step 7 – Sync tooling dependencies
800
+ # ------------------------------------------------------------------
801
+ ctx.run_command(["npm", "install"], cwd=project_dir)
802
+
803
+ # ------------------------------------------------------------------
804
+ # Step 8 – Sync Python dependencies
805
+ # ------------------------------------------------------------------
806
+ ctx.run_command(["uv", "sync"], cwd=project_dir)
807
+
808
+ # ------------------------------------------------------------------
809
+ # Step 9 – Requested project scaffolds
810
+ # ------------------------------------------------------------------
811
+ _run_requested_scaffolds(
812
+ project_name,
813
+ project_dir,
814
+ dry_run,
815
+ selected_scaffolds,
816
+ domain_migrations,
817
+ )
818
+
819
+ console.print(f"\n[bold green]Done![/] Project created at {project_dir}")
820
+ console.print(f" cd {project_name}")
821
+ if len(selected_scaffolds) == 1 and selected_scaffolds[0] == "instructions":
822
+ console.print(f" rpr generate domain {project_name}")