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/map.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from rpr.cli import app
9
+ from rpr.map.architecture import assess_architecture
10
+ from rpr.map.chains import build_diagnostic_chains, build_representative_chains
11
+ from rpr.map.classifier import classify
12
+ from rpr.map.coverage import build_coverage_map
13
+ from rpr.map.dependencies import build_external_dependencies
14
+ from rpr.map.extractor import analyze_project, build_raw_import_inventory
15
+ from rpr.map.output import write_map
16
+ from rpr.map.responsibility import build_responsibility_summaries
17
+ from rpr.map.topology import build_directory_topology
18
+ from rpr.ui.console import console
19
+
20
+
21
+ @app.command()
22
+ def map(
23
+ root: Annotated[
24
+ Path,
25
+ typer.Option("--root", help="Directory to analyze."),
26
+ ] = Path("."),
27
+ output: Annotated[
28
+ Path,
29
+ typer.Option("--output", help="Where to write the map.", dir_okay=False),
30
+ ] = Path("code-map.md"),
31
+ dry_run: Annotated[
32
+ bool, typer.Option("--dry-run", help="Preview actions without writing files.")
33
+ ] = False,
34
+ details: Annotated[
35
+ bool,
36
+ typer.Option(
37
+ "--details", help="Show all files and individual node edges in the diagram."
38
+ ),
39
+ ] = False,
40
+ focus: Annotated[
41
+ str | None,
42
+ typer.Option("--focus", help="Repo-relative directory to drill down into."),
43
+ ] = None,
44
+ ) -> None:
45
+ """Generate a structured code map of a project."""
46
+ resolved_root = root.resolve()
47
+
48
+ if not resolved_root.exists() or not resolved_root.is_dir():
49
+ console.print(
50
+ f"[bold red]Error:[/] --root '{root}' does not exist or is not a directory."
51
+ )
52
+ raise typer.Exit(1)
53
+
54
+ if focus is not None:
55
+ focus = focus.replace("\\", "/").strip("/")
56
+
57
+ graph = analyze_project(resolved_root)
58
+ classified = classify(graph)
59
+ topology = build_directory_topology(graph, resolved_root)
60
+ assessment = assess_architecture(graph, topology)
61
+ chains = build_representative_chains(graph, topology, assessment)
62
+ diagnostic_chains = (
63
+ build_diagnostic_chains(graph, topology, assessment) if details else None
64
+ )
65
+
66
+ raw_imports = build_raw_import_inventory(graph)
67
+ ext_deps = build_external_dependencies(graph, raw_imports, topology)
68
+ responsibilities = build_responsibility_summaries(topology, classified, resolved_root)
69
+ coverage = build_coverage_map(graph, classified, topology)
70
+
71
+ if focus is not None and focus not in topology.nodes:
72
+ console.print(
73
+ f"[bold red]Error:[/] --focus '{focus}' is not a directory in the analyzed project."
74
+ )
75
+ raise typer.Exit(1)
76
+
77
+ if dry_run:
78
+ console.print(f"Root to scan : {resolved_root}", soft_wrap=True)
79
+ console.print(f"Output path : {output}", soft_wrap=True)
80
+ write_map(
81
+ graph,
82
+ classified,
83
+ output,
84
+ resolved_root,
85
+ dry_run=True,
86
+ details=details,
87
+ topology=topology,
88
+ assessment=assessment,
89
+ chains=chains,
90
+ focus=focus,
91
+ diagnostic_chains=diagnostic_chains,
92
+ ext_deps=ext_deps,
93
+ responsibilities=responsibilities,
94
+ coverage=coverage,
95
+ )
96
+ console.print("No files written (dry-run mode).", soft_wrap=True)
97
+ raise typer.Exit(0)
98
+
99
+ write_map(
100
+ graph,
101
+ classified,
102
+ output,
103
+ resolved_root,
104
+ details=details,
105
+ topology=topology,
106
+ assessment=assessment,
107
+ chains=chains,
108
+ focus=focus,
109
+ diagnostic_chains=diagnostic_chains,
110
+ ext_deps=ext_deps,
111
+ responsibilities=responsibilities,
112
+ coverage=coverage,
113
+ )
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rpr.cli import app
7
+ from rpr.ui.theme import SETTING_DEFINITIONS, THEMES, settings_manager, theme_manager
8
+ from rpr.ui.console import console, refresh_console
9
+ from rpr.ui.renderers import render_heading, render_info, render_success, render_table
10
+
11
+ settings_app = typer.Typer(help="Manage RPR settings and themes.")
12
+ app.add_typer(settings_app, name="settings")
13
+
14
+
15
+ def _render_settings_overview() -> None:
16
+ rows = []
17
+ for definition, value in settings_manager.describe_settings():
18
+ choices = ", ".join(definition.choices) if definition.choices else "true, false"
19
+ rows.append([definition.key, value, choices, definition.description])
20
+ render_table("RPR Settings", ["Key", "Value", "Allowed", "Description"], rows)
21
+
22
+
23
+ def _set_setting_value(key: str, value: str) -> None:
24
+ try:
25
+ settings_manager.set_value(key, value)
26
+ except KeyError:
27
+ console.print(f"[error]Error:[/] Unknown setting: {key}")
28
+ raise typer.Exit(1) from None
29
+ except ValueError as exc:
30
+ console.print(f"[error]Error:[/] {exc}")
31
+ raise typer.Exit(1) from exc
32
+
33
+ if key == "theme":
34
+ refresh_console()
35
+ render_success(
36
+ f"Updated [bold]{key}[/] to [bold]{settings_manager.get_value(key)}[/]"
37
+ )
38
+
39
+
40
+ @settings_app.callback(invoke_without_command=True)
41
+ def settings_root(ctx: typer.Context) -> None:
42
+ """Inspect persisted RPR settings."""
43
+ if ctx.invoked_subcommand is None:
44
+ _render_settings_overview()
45
+
46
+
47
+ @settings_app.command("list")
48
+ def list_settings() -> None:
49
+ """List all persisted settings."""
50
+ _render_settings_overview()
51
+
52
+
53
+ @settings_app.command("get")
54
+ def get_setting(
55
+ key: Annotated[str, typer.Argument(help="Setting key to inspect.")],
56
+ ) -> None:
57
+ """Read a single persisted setting."""
58
+ if key not in SETTING_DEFINITIONS:
59
+ console.print(f"[error]Error:[/] Unknown setting: {key}")
60
+ raise typer.Exit(1)
61
+ render_info(f"{key}: [bold]{settings_manager.get_value(key)}[/]")
62
+
63
+
64
+ @settings_app.command("set")
65
+ def set_setting(
66
+ key: Annotated[str, typer.Argument(help="Setting key to update.")],
67
+ value: Annotated[str, typer.Argument(help="New value for the setting.")],
68
+ ) -> None:
69
+ """Update a persisted setting."""
70
+ _set_setting_value(key, value)
71
+
72
+
73
+ @settings_app.command("theme")
74
+ def set_theme(
75
+ name: Annotated[
76
+ str | None, typer.Argument(help="The name of the theme to use.")
77
+ ] = None,
78
+ list_themes: Annotated[
79
+ bool, typer.Option("--list", "-l", help="List all available themes.")
80
+ ] = False,
81
+ ) -> None:
82
+ """Get or set the terminal UI theme."""
83
+ if list_themes:
84
+ render_heading("Available Themes")
85
+ current = theme_manager.current_theme_name
86
+ for theme_name in THEMES:
87
+ star = "[success]*[/success] " if theme_name == current else " "
88
+ console.print(f"{star}{theme_name}")
89
+ return
90
+
91
+ if name is None:
92
+ render_info(f"Current theme: [bold]{theme_manager.current_theme_name}[/]")
93
+ return
94
+
95
+ try:
96
+ settings_manager.set_value("theme", name)
97
+ except ValueError as exc:
98
+ console.print(f"[error]Error:[/] {exc}")
99
+ raise typer.Exit(1) from exc
100
+
101
+ refresh_console()
102
+ render_success(f"Theme set to: [bold]{name}[/]")
rpr/commands/sync.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from rpr.cli import sync_app
10
+ from rpr.context import RunContext
11
+ from rpr.generators.base import SyncConfig, load_rules
12
+ from rpr.generators.claude import generate_claude_rules
13
+ from rpr.generators.copilot import generate_copilot_rules
14
+ from rpr.generators.cursor import generate_cursor_rules
15
+ from rpr.generators.gemini import generate_gemini_rules
16
+ from rpr.workspace import derive_variables
17
+
18
+ console = Console()
19
+
20
+ TARGET_GENERATORS = {
21
+ "cursor": generate_cursor_rules,
22
+ "copilot": generate_copilot_rules,
23
+ "claude": generate_claude_rules,
24
+ "gemini": generate_gemini_rules,
25
+ }
26
+
27
+
28
+ @sync_app.command("rules")
29
+ def rules(
30
+ target: Annotated[
31
+ str | None,
32
+ typer.Option(
33
+ "--target", "-t", help="Sync only one target: cursor, copilot, or claude."
34
+ ),
35
+ ] = None,
36
+ dry_run: Annotated[
37
+ bool, typer.Option("--dry-run", help="Preview changes without writing.")
38
+ ] = False,
39
+ ) -> None:
40
+ """Sync AI instructions to Cursor, Copilot, and Claude Code."""
41
+ ctx = RunContext(dry_run=dry_run, project_dir=Path.cwd())
42
+ config_path = ctx.project_dir / ".rpr.yaml"
43
+
44
+ config = SyncConfig.load(config_path)
45
+
46
+ if not config.source:
47
+ default_source = Path(__file__).parent.parent / "scaffolds" / "instructions"
48
+ if default_source.exists():
49
+ config.source = str(default_source)
50
+ else:
51
+ config.source = typer.prompt(
52
+ "Path to rules source directory",
53
+ default=str(default_source),
54
+ )
55
+ if not dry_run:
56
+ config.save(config_path)
57
+ console.print(f"[bold green]Saved:[/] {config_path}")
58
+
59
+ source_dir = Path(config.source).expanduser().resolve()
60
+ if not source_dir.exists():
61
+ console.print(f"[bold red]Error:[/] Source directory not found: {source_dir}")
62
+ raise typer.Exit(1)
63
+
64
+ if not config.variables:
65
+ console.print("[bold yellow]Warning:[/] No variables found in .rpr.yaml.")
66
+ project_name = typer.prompt("Project name", default=ctx.project_dir.name)
67
+ config.variables = derive_variables(project_name)
68
+ if not dry_run:
69
+ config.save(config_path)
70
+ console.print(f"[bold green]Updated variables in:[/] {config_path}")
71
+
72
+ all_rules = load_rules(source_dir)
73
+ if not all_rules:
74
+ # Fallback to source_dir if no "instructions" subdir exists
75
+ all_rules = load_rules(source_dir)
76
+
77
+ filtered = config.filter_rules(all_rules)
78
+
79
+ if not filtered:
80
+ console.print("[bold yellow]Warning:[/] No rules found matching the config.")
81
+ raise typer.Exit(0)
82
+
83
+ console.print(f"\n[bold]Syncing {len(filtered)} rules[/]\n")
84
+
85
+ targets = [target] if target else config.targets
86
+
87
+ for t in targets:
88
+ if t not in TARGET_GENERATORS:
89
+ console.print(
90
+ f"[bold red]Error:[/] Unknown target: {t}. Valid: cursor, copilot, claude, gemini"
91
+ )
92
+ continue
93
+
94
+ console.print(f"[bold]Target:[/] {t}")
95
+ TARGET_GENERATORS[t](filtered, ctx.project_dir, config.variables, ctx)
96
+
97
+ console.print(f"\n[bold green]Done![/] Rules synced for: {', '.join(targets)}")
rpr/context.py ADDED
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from rpr.ui.console import console
9
+ from rpr.ui.markdown import DEFAULT_MARKDOWN_PREVIEW_LINES, render_markdown
10
+ from rpr.ui.renderers import render_command_start, render_file_preview
11
+
12
+
13
+ def _should_preview_markdown(path: Path, preview_markdown: bool) -> bool:
14
+ return preview_markdown and path.suffix.lower() in {".md", ".mdc"}
15
+
16
+
17
+ def _render_markdown_file_preview(
18
+ path: Path,
19
+ content: str,
20
+ *,
21
+ is_dry_run: bool,
22
+ is_update: bool,
23
+ title: str | None,
24
+ max_lines: int | None,
25
+ ) -> None:
26
+ if is_dry_run:
27
+ action = "update" if is_update else "create"
28
+ console.print(
29
+ f"\n[dry_run][DRY RUN][/] Would {action}: [path]{path}[/]", soft_wrap=True
30
+ )
31
+
32
+ render_markdown(
33
+ content,
34
+ mode="preview",
35
+ title=title or path.name,
36
+ max_lines=max_lines,
37
+ )
38
+
39
+
40
+ def _format_dependency_groups(
41
+ *, runtime: list[str] | None = None, dev: list[str] | None = None
42
+ ) -> str:
43
+ groups: list[str] = []
44
+ if runtime:
45
+ groups.append(f"runtime=[{', '.join(runtime)}]")
46
+ if dev:
47
+ groups.append(f"dev=[{', '.join(dev)}]")
48
+ return " ".join(groups)
49
+
50
+
51
+ @dataclass
52
+ class RunContext:
53
+ dry_run: bool = False
54
+ project_dir: Path = field(default_factory=Path.cwd)
55
+
56
+ def preview_dependencies(
57
+ self,
58
+ *,
59
+ manager: str,
60
+ target: Path | str,
61
+ runtime: list[str] | None = None,
62
+ dev: list[str] | None = None,
63
+ ) -> None:
64
+ scope = "Would install" if self.dry_run else "Installing"
65
+ groups = _format_dependency_groups(runtime=runtime, dev=dev)
66
+ message = f"{scope} {manager} dependencies for {target}"
67
+ if groups:
68
+ message = f"{message}: {groups}"
69
+ console.print(
70
+ f"[DRY RUN] {message}" if self.dry_run else message,
71
+ soft_wrap=True,
72
+ markup=False,
73
+ )
74
+
75
+ def run_command(
76
+ self,
77
+ cmd: list[str],
78
+ *,
79
+ cwd: Path | str | None = None,
80
+ env: dict[str, str] | None = None,
81
+ capture: bool = False,
82
+ supports_dry_run: bool = False,
83
+ ) -> subprocess.CompletedProcess[str]:
84
+ resolved_cwd = str(cwd) if cwd else str(self.project_dir)
85
+ env_prefix = " ".join(f"{key}={value}" for key, value in (env or {}).items())
86
+
87
+ display_cmd = " ".join(cmd)
88
+ if env_prefix:
89
+ display_cmd = f"{env_prefix} {display_cmd}"
90
+
91
+ if self.dry_run and not supports_dry_run:
92
+ render_command_start(display_cmd, resolved_cwd, is_dry_run=True)
93
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
94
+
95
+ effective_cmd = (
96
+ cmd + ["--dry-run"] if (self.dry_run and supports_dry_run) else cmd
97
+ )
98
+ if self.dry_run and supports_dry_run:
99
+ display_cmd = " ".join(effective_cmd)
100
+ if env_prefix:
101
+ display_cmd = f"{env_prefix} {display_cmd}"
102
+
103
+ render_command_start(display_cmd, resolved_cwd, is_dry_run=False)
104
+
105
+ effective_env = None
106
+ if env:
107
+ effective_env = os.environ.copy()
108
+ effective_env.update(env)
109
+ result = subprocess.run(
110
+ effective_cmd,
111
+ cwd=resolved_cwd,
112
+ env=effective_env,
113
+ capture_output=capture,
114
+ text=True,
115
+ check=True,
116
+ )
117
+ return result
118
+
119
+ def write_file(
120
+ self,
121
+ path: Path,
122
+ content: str,
123
+ *,
124
+ preview_markdown: bool = False,
125
+ markdown_title: str | None = None,
126
+ markdown_max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
127
+ ) -> None:
128
+ if self.dry_run:
129
+ if _should_preview_markdown(path, preview_markdown):
130
+ _render_markdown_file_preview(
131
+ path,
132
+ content,
133
+ is_dry_run=True,
134
+ is_update=False,
135
+ title=markdown_title,
136
+ max_lines=markdown_max_lines,
137
+ )
138
+ return
139
+ render_file_preview(path, content, is_dry_run=True, is_update=False)
140
+ return
141
+
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ path.write_text(content)
144
+ render_file_preview(path, content, is_dry_run=False, is_update=False)
145
+ if _should_preview_markdown(path, preview_markdown):
146
+ _render_markdown_file_preview(
147
+ path,
148
+ content,
149
+ is_dry_run=False,
150
+ is_update=False,
151
+ title=markdown_title,
152
+ max_lines=markdown_max_lines,
153
+ )
154
+
155
+ def ensure_dir(self, path: Path) -> None:
156
+ if self.dry_run:
157
+ from rpr.ui.console import console
158
+
159
+ console.print(
160
+ f"[dry_run][DRY RUN][/] Would create directory: [path]{path}[/]",
161
+ soft_wrap=True,
162
+ )
163
+ return
164
+
165
+ path.mkdir(parents=True, exist_ok=True)
166
+ from rpr.ui.console import console
167
+
168
+ console.print(f"[success]Created:[/] [path]{path}/[/]")
169
+
170
+ def update_file(
171
+ self,
172
+ path: Path,
173
+ content: str,
174
+ *,
175
+ preview_markdown: bool = False,
176
+ markdown_title: str | None = None,
177
+ markdown_max_lines: int | None = DEFAULT_MARKDOWN_PREVIEW_LINES,
178
+ ) -> None:
179
+ if self.dry_run:
180
+ if _should_preview_markdown(path, preview_markdown):
181
+ _render_markdown_file_preview(
182
+ path,
183
+ content,
184
+ is_dry_run=True,
185
+ is_update=True,
186
+ title=markdown_title,
187
+ max_lines=markdown_max_lines,
188
+ )
189
+ return
190
+ render_file_preview(path, content, is_dry_run=True, is_update=True)
191
+ return
192
+
193
+ path.write_text(content)
194
+ render_file_preview(path, content, is_dry_run=False, is_update=True)
195
+ if _should_preview_markdown(path, preview_markdown):
196
+ _render_markdown_file_preview(
197
+ path,
198
+ content,
199
+ is_dry_run=False,
200
+ is_update=True,
201
+ title=markdown_title,
202
+ max_lines=markdown_max_lines,
203
+ )
File without changes
rpr/generators/base.py ADDED
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class Rule:
12
+ id: str
13
+ content: str
14
+ description: str = ""
15
+ apply_to: str = "**"
16
+ rule_type: str = "instruction"
17
+ language: str = "all"
18
+ source_path: Path | None = None
19
+
20
+
21
+ def load_rules(source_dir: Path) -> list[Rule]:
22
+ """Load all markdown rules from a source directory, parsing YAML frontmatter."""
23
+ rules: list[Rule] = []
24
+
25
+ for md_file in sorted(source_dir.rglob("*.md")):
26
+ if ".venv" in md_file.parts:
27
+ continue
28
+
29
+ text = md_file.read_text()
30
+ rule_id = md_file.stem.replace(".instructions", "")
31
+ frontmatter: dict = {}
32
+ body = text
33
+
34
+ fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
35
+ if fm_match:
36
+ try:
37
+ frontmatter = yaml.safe_load(fm_match.group(1)) or {}
38
+ except yaml.YAMLError:
39
+ frontmatter = {}
40
+ body = fm_match.group(2)
41
+
42
+ first_heading = ""
43
+ heading_match = re.search(r"^#+\s+(.+)$", body, re.MULTILINE)
44
+ if heading_match:
45
+ first_heading = heading_match.group(1).strip()
46
+
47
+ rules.append(
48
+ Rule(
49
+ id=rule_id,
50
+ content=body.strip(),
51
+ description=frontmatter.get("description", first_heading),
52
+ apply_to=frontmatter.get("applyTo", "**"),
53
+ rule_type=frontmatter.get("type", "instruction"),
54
+ language=frontmatter.get("language", "all"),
55
+ source_path=md_file,
56
+ )
57
+ )
58
+
59
+ return rules
60
+
61
+
62
+ def interpolate(text: str, variables: dict[str, str]) -> str:
63
+ """Replace {variable} placeholders with values from the variables dict."""
64
+ if not variables:
65
+ return text
66
+ for key, value in variables.items():
67
+ text = text.replace(f"{{{key}}}", value)
68
+ return text
69
+
70
+
71
+ @dataclass
72
+ class SyncConfig:
73
+ source: str = ""
74
+ targets: list[str] = field(default_factory=lambda: ["cursor", "copilot", "claude"])
75
+ include: list[str] = field(default_factory=list)
76
+ exclude: list[str] = field(default_factory=list)
77
+ variables: dict[str, str] = field(default_factory=dict)
78
+
79
+ @classmethod
80
+ def load(cls, path: Path) -> SyncConfig:
81
+ if not path.exists():
82
+ return cls()
83
+ try:
84
+ data = yaml.safe_load(path.read_text()) or {}
85
+ except yaml.YAMLError:
86
+ data = {}
87
+ return cls(
88
+ source=data.get("source", ""),
89
+ targets=data.get("targets", ["cursor", "copilot", "claude"]),
90
+ include=data.get("include", []),
91
+ exclude=data.get("exclude", []),
92
+ variables=data.get("variables", {}),
93
+ )
94
+
95
+ def save(self, path: Path) -> None:
96
+ data = {
97
+ "source": self.source,
98
+ "targets": self.targets,
99
+ "include": self.include,
100
+ "exclude": self.exclude,
101
+ "variables": self.variables,
102
+ }
103
+ path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
104
+
105
+ def filter_rules(self, rules: list[Rule]) -> list[Rule]:
106
+ if self.include:
107
+ rules = [r for r in rules if r.id in self.include]
108
+ if self.exclude:
109
+ rules = [r for r in rules if r.id not in self.exclude]
110
+ return rules
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rpr.context import RunContext
6
+ from rpr.generators.base import Rule, interpolate
7
+
8
+ HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
9
+
10
+
11
+ def generate_claude_rules(
12
+ rules: list[Rule],
13
+ project_dir: Path,
14
+ variables: dict[str, str],
15
+ ctx: RunContext,
16
+ ) -> None:
17
+ sections: list[str] = [HEADER.strip()]
18
+
19
+ for rule in rules:
20
+ content = interpolate(rule.content, variables)
21
+ section_header = (
22
+ f"## {rule.description}" if rule.description else f"## {rule.id}"
23
+ )
24
+ sections.append(f"{section_header}\n\n{content}")
25
+
26
+ full_content = "\n\n---\n\n".join(sections) + "\n"
27
+ ctx.write_file(
28
+ project_dir / "CLAUDE.md",
29
+ full_content,
30
+ preview_markdown=True,
31
+ markdown_title="Claude Instructions Preview",
32
+ markdown_max_lines=80,
33
+ )
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rpr.context import RunContext
6
+ from rpr.generators.base import Rule, interpolate
7
+
8
+ HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
9
+
10
+
11
+ def generate_copilot_rules(
12
+ rules: list[Rule],
13
+ project_dir: Path,
14
+ variables: dict[str, str],
15
+ ctx: RunContext,
16
+ ) -> None:
17
+ instructions_dir = project_dir / ".github"
18
+ ctx.ensure_dir(instructions_dir)
19
+
20
+ global_rules = [r for r in rules if r.apply_to == "**"]
21
+
22
+ # Copilot custom instructions only support a single file.
23
+ # Scoped rules are ignored here as they don't map cleanly to a global context.
24
+
25
+ if global_rules:
26
+ global_content = HEADER
27
+ for rule in global_rules:
28
+ content = interpolate(rule.content, variables)
29
+ global_content += f"\n{content}\n\n---\n"
30
+ ctx.write_file(
31
+ instructions_dir / "copilot-instructions.md",
32
+ global_content,
33
+ preview_markdown=True,
34
+ markdown_title="Copilot Instructions Preview",
35
+ markdown_max_lines=80,
36
+ )