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
@@ -0,0 +1,543 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shlex
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import ClassVar, Protocol
8
+
9
+ import typer
10
+
11
+ from rpr.agent.bootstrap import ChatRuntimeBundle
12
+ from rpr.agent.control import TurnControl
13
+ from rpr.application.catalog import COMMAND_CATALOG, chat_commands, usage_string
14
+ from rpr.application.checks import collect_check_report
15
+ from rpr.ui.theme import SETTING_DEFINITIONS, settings_manager
16
+
17
+
18
+ class ShellArgumentParser(argparse.ArgumentParser):
19
+ def error(self, message: str) -> None:
20
+ raise ValueError(message)
21
+
22
+
23
+ class ShellPresenter(Protocol):
24
+ def flush(self) -> None: ...
25
+
26
+ def heading(self, text: str) -> None: ...
27
+
28
+ def info(self, message: str) -> None: ...
29
+
30
+ def success(self, message: str) -> None: ...
31
+
32
+ def warning(self, message: str) -> None: ...
33
+
34
+ def error(self, message: str) -> None: ...
35
+
36
+ def table(self, title: str, columns: list[str], rows: list[list[str]]) -> None: ...
37
+
38
+ def status_row(
39
+ self,
40
+ icon_char: str,
41
+ area: str,
42
+ path: str,
43
+ status: bool,
44
+ suggestion: str | None = None,
45
+ ) -> None: ...
46
+
47
+ def on_setting_changed(self, key: str) -> None: ...
48
+
49
+
50
+ class ShellInputService(Protocol):
51
+ async def prompt(self, label: str, *, default: str | None = None) -> str: ...
52
+
53
+ async def confirm(self, label: str, *, default: bool = False) -> bool: ...
54
+
55
+
56
+ @dataclass(slots=True, frozen=True)
57
+ class SlashCommandSpec:
58
+ name: str
59
+ usage: str
60
+ description: str
61
+
62
+
63
+ @dataclass(slots=True, frozen=True)
64
+ class ParsedSlashCommand:
65
+ name: str
66
+ args: list[str]
67
+
68
+
69
+ class CommandShellService:
70
+ _instance: ClassVar[CommandShellService | None] = None
71
+
72
+ def __init__(self) -> None:
73
+ self._bundle: ChatRuntimeBundle | None = None
74
+ self._presenter: ShellPresenter | None = None
75
+ self._prompts: ShellInputService | None = None
76
+ self._command_specs = {
77
+ cmd.name: SlashCommandSpec(
78
+ name=cmd.name,
79
+ usage=usage_string(cmd),
80
+ description=cmd.description,
81
+ )
82
+ for cmd in chat_commands(COMMAND_CATALOG)
83
+ }
84
+
85
+ @classmethod
86
+ def instance(cls) -> CommandShellService:
87
+ if cls._instance is None:
88
+ cls._instance = cls()
89
+ return cls._instance
90
+
91
+ def configure(
92
+ self,
93
+ *,
94
+ bundle: ChatRuntimeBundle,
95
+ presenter: ShellPresenter,
96
+ prompts: ShellInputService,
97
+ ) -> None:
98
+ self._bundle = bundle
99
+ self._presenter = presenter
100
+ self._prompts = prompts
101
+
102
+ @property
103
+ def command_specs(self) -> list[SlashCommandSpec]:
104
+ return [self._command_specs[name] for name in sorted(self._command_specs)]
105
+
106
+ @property
107
+ def _active_bundle(self) -> ChatRuntimeBundle:
108
+ if self._bundle is None:
109
+ raise RuntimeError("Command shell service is not configured.")
110
+ return self._bundle
111
+
112
+ @property
113
+ def _active_presenter(self) -> ShellPresenter:
114
+ if self._presenter is None:
115
+ raise RuntimeError("Command shell service is not configured.")
116
+ return self._presenter
117
+
118
+ @property
119
+ def _active_prompts(self) -> ShellInputService:
120
+ if self._prompts is None:
121
+ raise RuntimeError("Command shell service is not configured.")
122
+ return self._prompts
123
+
124
+ async def handle_input(
125
+ self, user_input: str, *, control: TurnControl | None = None
126
+ ) -> bool:
127
+ normalized = user_input.strip()
128
+ if not normalized:
129
+ return True
130
+
131
+ if normalized.lower() in {"exit", "quit"}:
132
+ normalized = "/exit"
133
+
134
+ if not normalized.startswith("/"):
135
+ await self._active_bundle.runtime.execute_turn(normalized, control=control)
136
+ return True
137
+
138
+ parsed = self._parse_slash_command(normalized)
139
+ if parsed is None:
140
+ return True
141
+
142
+ handlers = {
143
+ "help": self._handle_help,
144
+ "exit": self._handle_exit,
145
+ "settings": self._handle_settings,
146
+ "init": self._handle_init,
147
+ "generate": self._handle_generate,
148
+ "sync": self._handle_sync,
149
+ "add": self._handle_add,
150
+ "check": self._handle_check,
151
+ "map": self._handle_map,
152
+ }
153
+ handler = handlers.get(parsed.name)
154
+ if handler is None:
155
+ self._active_presenter.error(f"Unknown slash command: /{parsed.name}")
156
+ self._active_presenter.info(
157
+ "Run /help to inspect the supported command surface."
158
+ )
159
+ return True
160
+
161
+ try:
162
+ return await handler(parsed)
163
+ except ValueError as exc:
164
+ self._active_presenter.error(str(exc))
165
+ return True
166
+ except Exception as exc:
167
+ self._active_presenter.error(f"Command failed: {exc}")
168
+ return True
169
+
170
+ def _parse_slash_command(self, raw_input: str) -> ParsedSlashCommand | None:
171
+ command_body = raw_input[1:].strip()
172
+ if not command_body:
173
+ self._active_presenter.error(
174
+ "Empty slash command. Run /help to inspect the command surface."
175
+ )
176
+ return None
177
+
178
+ try:
179
+ tokens = shlex.split(command_body)
180
+ except ValueError as exc:
181
+ self._active_presenter.error(f"Unable to parse command: {exc}")
182
+ return None
183
+
184
+ if not tokens:
185
+ self._active_presenter.error(
186
+ "Empty slash command. Run /help to inspect the command surface."
187
+ )
188
+ return None
189
+
190
+ return ParsedSlashCommand(name=tokens[0].lower(), args=tokens[1:])
191
+
192
+ def _parse_args(
193
+ self, parser: ShellArgumentParser, args: list[str]
194
+ ) -> argparse.Namespace | None:
195
+ try:
196
+ return parser.parse_args(args)
197
+ except ValueError as exc:
198
+ self._active_presenter.error(str(exc))
199
+ return None
200
+
201
+ def _invoke_command(self, callback, /, *args, **kwargs) -> int:
202
+ try:
203
+ callback(*args, **kwargs)
204
+ except typer.Exit as exc:
205
+ return int(exc.exit_code or 0)
206
+ return 0
207
+
208
+ async def _handle_help(self, parsed: ParsedSlashCommand) -> bool:
209
+ del parsed
210
+ rows = [
211
+ [spec.name, spec.usage, spec.description] for spec in self.command_specs
212
+ ]
213
+ self._active_presenter.table(
214
+ "Slash Commands", ["Command", "Usage", "Description"], rows
215
+ )
216
+ self._active_presenter.info(
217
+ "Session state: "
218
+ f"approval_mode={self._active_bundle.session.approval_mode.value}, "
219
+ f"project_root={self._active_bundle.ctx.project_dir}"
220
+ )
221
+ return True
222
+
223
+ async def _handle_exit(self, parsed: ParsedSlashCommand) -> bool:
224
+ del parsed
225
+ self._active_presenter.info("Session ended.")
226
+ return False
227
+
228
+ async def _handle_settings(self, parsed: ParsedSlashCommand) -> bool:
229
+ parser = ShellArgumentParser(prog="/settings", add_help=False)
230
+ parser.add_argument("action", nargs="?")
231
+ parser.add_argument("key", nargs="?")
232
+ parser.add_argument("value", nargs="?")
233
+ namespace = self._parse_args(parser, parsed.args)
234
+ if namespace is None:
235
+ return True
236
+
237
+ action = None if namespace.action is None else namespace.action.lower()
238
+ if action in {None, "list"}:
239
+ self._render_settings_table()
240
+ if action == "list":
241
+ return True
242
+ return await self._interactive_settings_update()
243
+
244
+ if action == "get":
245
+ if not namespace.key:
246
+ raise ValueError("Usage: /settings get <key>")
247
+ self._ensure_setting_exists(namespace.key)
248
+ self._active_presenter.info(
249
+ f"{namespace.key}: {self._format_setting_value(namespace.key)}"
250
+ )
251
+ return True
252
+
253
+ if action == "set":
254
+ if not namespace.key:
255
+ raise ValueError("Usage: /settings set <key> <value>")
256
+ self._ensure_setting_exists(namespace.key)
257
+ value = namespace.value
258
+ if value is None:
259
+ value = await self._active_prompts.prompt(
260
+ f"New value for {namespace.key}",
261
+ default=self._format_setting_value(namespace.key),
262
+ )
263
+ return await self._apply_setting_change(namespace.key, value)
264
+
265
+ raise ValueError(
266
+ "Usage: /settings | /settings get <key> | /settings set <key> <value>"
267
+ )
268
+
269
+ async def _handle_init(self, parsed: ParsedSlashCommand) -> bool:
270
+ from rpr.commands import init as init_command
271
+
272
+ parser = ShellArgumentParser(prog="/init", add_help=False)
273
+ parser.add_argument("project_name", nargs="?")
274
+ parser.add_argument("--dry-run", action="store_true")
275
+ parser.add_argument("--python", default="3.14")
276
+ parser.add_argument("--integrated", action="store_true")
277
+ namespace = self._parse_args(parser, parsed.args)
278
+ if namespace is None:
279
+ return True
280
+
281
+ project_name = namespace.project_name or await self._active_prompts.prompt(
282
+ "Project name"
283
+ )
284
+ if not project_name.strip():
285
+ self._active_presenter.warning("Initialization cancelled.")
286
+ return True
287
+
288
+ self._active_presenter.info(f"Running /init for {project_name}.")
289
+ exit_code = self._invoke_command(
290
+ init_command.init,
291
+ project_name=project_name,
292
+ dry_run=namespace.dry_run,
293
+ python_version=namespace.python,
294
+ integrated=namespace.integrated,
295
+ )
296
+ if exit_code:
297
+ self._active_presenter.error(f"/init finished with exit code {exit_code}.")
298
+ return True
299
+
300
+ async def _handle_generate(self, parsed: ParsedSlashCommand) -> bool:
301
+ from rpr.commands.generate import api as generate_api_command
302
+ from rpr.commands.generate import domain as generate_domain_command
303
+ from rpr.commands.generate import engine as generate_engine_command
304
+ from rpr.commands.generate import storybook as generate_storybook_command
305
+ from rpr.commands.generate import ui as generate_ui_command
306
+
307
+ parser = ShellArgumentParser(prog="/generate", add_help=False)
308
+ subparsers = parser.add_subparsers(dest="target")
309
+
310
+ for name in ("domain", "api", "ui", "engine"):
311
+ subparser = subparsers.add_parser(name, add_help=False)
312
+ subparser.add_argument("name", nargs="?")
313
+ subparser.add_argument("--dry-run", action="store_true")
314
+
315
+ storybook_parser = subparsers.add_parser("storybook", add_help=False)
316
+ storybook_parser.add_argument("--dry-run", action="store_true")
317
+ storybook_parser.add_argument("--ui-package")
318
+
319
+ namespace = self._parse_args(parser, parsed.args)
320
+ if namespace is None:
321
+ return True
322
+ if namespace.target is None:
323
+ raise ValueError("Usage: /generate <domain|api|ui|engine|storybook> ...")
324
+
325
+ if namespace.target == "domain":
326
+ name = namespace.name or await self._active_prompts.prompt("App name")
327
+ self._invoke_command(
328
+ generate_domain_command.domain, name=name, dry_run=namespace.dry_run
329
+ )
330
+ return True
331
+ if namespace.target == "api":
332
+ name = namespace.name or await self._active_prompts.prompt("App name")
333
+ self._invoke_command(
334
+ generate_api_command.api, name=name, dry_run=namespace.dry_run
335
+ )
336
+ return True
337
+ if namespace.target == "ui":
338
+ name = namespace.name or await self._active_prompts.prompt("App name")
339
+ self._invoke_command(
340
+ generate_ui_command.ui, name=name, dry_run=namespace.dry_run
341
+ )
342
+ return True
343
+ if namespace.target == "engine":
344
+ name = namespace.name or await self._active_prompts.prompt("App name")
345
+ self._invoke_command(
346
+ generate_engine_command.engine, name=name, dry_run=namespace.dry_run
347
+ )
348
+ return True
349
+
350
+ ui_package = namespace.ui_package
351
+ if not ui_package:
352
+ ui_packages = generate_storybook_command._find_ui_packages(
353
+ self._bundle.ctx.project_dir
354
+ )
355
+ if len(ui_packages) == 1:
356
+ ui_package = ui_packages[0]
357
+ elif len(ui_packages) > 1:
358
+ self._active_presenter.table(
359
+ "Storybook Targets",
360
+ ["Package"],
361
+ [[package] for package in ui_packages],
362
+ )
363
+ ui_package = (
364
+ await self._active_prompts.prompt(
365
+ "UI package", default=ui_packages[0]
366
+ )
367
+ ).strip() or ui_packages[0]
368
+
369
+ self._invoke_command(
370
+ generate_storybook_command.storybook,
371
+ dry_run=namespace.dry_run,
372
+ ui_package=ui_package,
373
+ )
374
+ return True
375
+
376
+ async def _handle_sync(self, parsed: ParsedSlashCommand) -> bool:
377
+ from rpr.commands import sync as sync_command
378
+
379
+ parser = ShellArgumentParser(prog="/sync", add_help=False)
380
+ parser.add_argument("action", nargs="?", default="rules")
381
+ parser.add_argument("--target")
382
+ parser.add_argument("--dry-run", action="store_true")
383
+ namespace = self._parse_args(parser, parsed.args)
384
+ if namespace is None:
385
+ return True
386
+ if namespace.action != "rules":
387
+ raise ValueError("Usage: /sync [rules] [--target cursor] [--dry-run]")
388
+
389
+ self._invoke_command(
390
+ sync_command.rules, target=namespace.target, dry_run=namespace.dry_run
391
+ )
392
+ return True
393
+
394
+ async def _handle_add(self, parsed: ParsedSlashCommand) -> bool:
395
+ from rpr.commands import add as add_command
396
+
397
+ parser = ShellArgumentParser(prog="/add", add_help=False)
398
+ parser.add_argument("action", nargs="?", default="template")
399
+ parser.add_argument("template_id", nargs="?")
400
+ parser.add_argument("--list", action="store_true")
401
+ parser.add_argument("--force", action="store_true")
402
+ parser.add_argument("--name")
403
+ parser.add_argument("--source")
404
+ parser.add_argument("--dry-run", action="store_true")
405
+ namespace = self._parse_args(parser, parsed.args)
406
+ if namespace is None:
407
+ return True
408
+ if namespace.action != "template":
409
+ raise ValueError(
410
+ "Usage: /add template <template-id> [--name app] [--force] [--dry-run]"
411
+ )
412
+
413
+ template_id = namespace.template_id
414
+ if template_id is None and not namespace.list:
415
+ template_id = await self._active_prompts.prompt("Template ID")
416
+
417
+ self._invoke_command(
418
+ add_command.template,
419
+ template_id=template_id,
420
+ list_templates=namespace.list,
421
+ force=namespace.force,
422
+ name=namespace.name,
423
+ source=namespace.source,
424
+ dry_run=namespace.dry_run,
425
+ )
426
+ return True
427
+
428
+ async def _handle_check(self, parsed: ParsedSlashCommand) -> bool:
429
+ parser = ShellArgumentParser(prog="/check", add_help=False)
430
+ parser.add_argument("--group")
431
+ namespace = self._parse_args(parser, parsed.args)
432
+ if namespace is None:
433
+ return True
434
+
435
+ report = collect_check_report(
436
+ self._active_bundle.ctx.project_dir, group=namespace.group
437
+ )
438
+ self._active_presenter.heading(f"rpr check — project: {report.project_name}")
439
+ current_group = None
440
+ for result in report.results:
441
+ if result.group != current_group:
442
+ current_group = result.group
443
+ self._active_presenter.info(current_group)
444
+ self._active_presenter.status_row(
445
+ "✓" if result.status else "✗",
446
+ result.area,
447
+ str(result.path),
448
+ result.status,
449
+ result.suggestion,
450
+ )
451
+
452
+ failures = sum(1 for result in report.results if not result.status)
453
+ if failures:
454
+ self._active_presenter.error(
455
+ f"Check completed with {failures} failing item(s)."
456
+ )
457
+ else:
458
+ self._active_presenter.success("Check completed with no failing items.")
459
+ return True
460
+
461
+ async def _handle_map(self, parsed: ParsedSlashCommand) -> bool:
462
+ from rpr.commands import map as map_command
463
+
464
+ parser = ShellArgumentParser(prog="/map", add_help=False)
465
+ parser.add_argument("--root", default=".")
466
+ parser.add_argument("--output", default="code-map.md")
467
+ parser.add_argument("--dry-run", action="store_true")
468
+ namespace = self._parse_args(parser, parsed.args)
469
+ if namespace is None:
470
+ return True
471
+
472
+ exit_code = self._invoke_command(
473
+ map_command.map,
474
+ root=Path(namespace.root),
475
+ output=Path(namespace.output),
476
+ dry_run=namespace.dry_run,
477
+ )
478
+ if exit_code:
479
+ self._active_presenter.error(f"/map finished with exit code {exit_code}.")
480
+ return True
481
+
482
+ def _render_settings_table(self) -> None:
483
+ rows = []
484
+ for definition, value in settings_manager.describe_settings():
485
+ allowed = (
486
+ ", ".join(definition.choices) if definition.choices else "true, false"
487
+ )
488
+ rows.append([definition.key, value, allowed, definition.description])
489
+ self._active_presenter.table(
490
+ "RPR Settings", ["Key", "Value", "Allowed", "Description"], rows
491
+ )
492
+
493
+ async def _interactive_settings_update(self) -> bool:
494
+ key = (
495
+ await self._active_prompts.prompt("Setting to edit (blank to cancel)")
496
+ ).strip()
497
+ if not key:
498
+ self._active_presenter.info("Settings update cancelled.")
499
+ return True
500
+
501
+ self._ensure_setting_exists(key)
502
+ definition = SETTING_DEFINITIONS[key]
503
+ allowed = ", ".join(definition.choices) if definition.choices else "true, false"
504
+ self._active_presenter.info(f"Current {key}: {self._format_setting_value(key)}")
505
+ self._active_presenter.info(f"Allowed values: {allowed}")
506
+ value = await self._active_prompts.prompt(
507
+ f"New value for {key}",
508
+ default=self._format_setting_value(key),
509
+ )
510
+ return await self._apply_setting_change(key, value)
511
+
512
+ def _ensure_setting_exists(self, key: str) -> None:
513
+ if key not in SETTING_DEFINITIONS:
514
+ raise ValueError(f"Unknown setting: {key}")
515
+
516
+ def _format_setting_value(self, key: str) -> str:
517
+ value = settings_manager.get_value(key)
518
+ if isinstance(value, bool):
519
+ return "true" if value else "false"
520
+ return value
521
+
522
+ async def _apply_setting_change(self, key: str, value: str) -> bool:
523
+ self._ensure_setting_exists(key)
524
+ if not await self._active_prompts.confirm(
525
+ f"Persist {key} = {value}?", default=True
526
+ ):
527
+ self._active_presenter.info("Settings update cancelled.")
528
+ return True
529
+
530
+ try:
531
+ settings_manager.set_value(key, value)
532
+ except ValueError as exc:
533
+ self._active_presenter.error(str(exc))
534
+ return True
535
+
536
+ if key == "approval_mode":
537
+ self._active_bundle.session.approval_mode = settings_manager.approval_mode
538
+
539
+ self._active_presenter.on_setting_changed(key)
540
+ self._active_presenter.success(
541
+ f"Updated {key} to {self._format_setting_value(key)}."
542
+ )
543
+ return True
rpr/checks/__init__.py ADDED
File without changes
rpr/checks/base.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class CheckResult:
9
+ area: str
10
+ status: bool
11
+ path: str
12
+ suggestion: str
13
+ group: str
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rpr.checks.base import CheckResult
6
+ from rpr.generators.base import SyncConfig, interpolate
7
+ from rpr.templates.registry import TEMPLATES
8
+
9
+
10
+ def check_instructions(
11
+ project_dir: Path, variables: dict[str, str]
12
+ ) -> list[CheckResult]:
13
+ results = []
14
+
15
+ # Cursor rules
16
+ cursor_rules = project_dir / ".cursor" / "rules"
17
+ cursor_ok = cursor_rules.exists() and any(cursor_rules.glob("*.mdc"))
18
+ results.append(
19
+ CheckResult(
20
+ group="AI Instructions & Templates",
21
+ area="Cursor rules",
22
+ status=cursor_ok,
23
+ path=str(cursor_rules) if cursor_ok else ".cursor/rules/ missing",
24
+ suggestion="rpr sync rules --target cursor",
25
+ )
26
+ )
27
+
28
+ # Copilot instructions
29
+ copilot_file = project_dir / ".github" / "copilot-instructions.md"
30
+ results.append(
31
+ CheckResult(
32
+ group="AI Instructions & Templates",
33
+ area="Copilot instructions",
34
+ status=copilot_file.exists(),
35
+ path=str(copilot_file)
36
+ if copilot_file.exists()
37
+ else ".github/copilot-instructions.md missing",
38
+ suggestion="rpr sync rules --target copilot",
39
+ )
40
+ )
41
+
42
+ # Claude instructions
43
+ claude_file = project_dir / "CLAUDE.md"
44
+ results.append(
45
+ CheckResult(
46
+ group="AI Instructions & Templates",
47
+ area="Claude instructions",
48
+ status=claude_file.exists(),
49
+ path=str(claude_file) if claude_file.exists() else "CLAUDE.md missing",
50
+ suggestion="rpr sync rules --target claude",
51
+ )
52
+ )
53
+
54
+ # Templates (only if domain package exists for Python, or UI library for JS)
55
+ has_domain = any(project_dir.glob("packages/python/domain"))
56
+ has_ui = any(project_dir.glob("packages/node/*-ui"))
57
+
58
+ template_ids = [
59
+ ("domain_settings", "has_domain"),
60
+ ("domain_container", "has_domain"),
61
+ ("mapper_util", "has_domain"),
62
+ ("dto_util", "has_domain"),
63
+ ("partial_update", "has_domain"),
64
+ ("encrypted_column", "has_domain"),
65
+ ("fetch_service", "has_ui"),
66
+ ("sticky_navigation", "has_ui"),
67
+ ]
68
+
69
+ for tid, prereq in template_ids:
70
+ if (prereq == "has_domain" and not has_domain) or (
71
+ prereq == "has_ui" and not has_ui
72
+ ):
73
+ continue
74
+
75
+ entry = TEMPLATES[tid]
76
+ target_path_str = interpolate(entry.target_path, variables)
77
+ target_path = project_dir / target_path_str
78
+
79
+ results.append(
80
+ CheckResult(
81
+ group="AI Instructions & Templates",
82
+ area=tid,
83
+ status=target_path.exists(),
84
+ path=str(target_path) if target_path.exists() else f"{tid} missing",
85
+ suggestion=f"rpr add template {tid}",
86
+ )
87
+ )
88
+
89
+ return results