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/ui/theme.py ADDED
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from rich.theme import Theme
8
+
9
+
10
+ @dataclass
11
+ class UIStyles:
12
+ success: str = "green"
13
+ warning: str = "yellow"
14
+ error: str = "red"
15
+ info: str = "blue"
16
+ muted: str = "dim"
17
+ heading: str = "bold"
18
+ command: str = "bold blue"
19
+ dry_run: str = "bold yellow"
20
+ path: str = "cyan"
21
+ line_number: str = "dim"
22
+ border: str = "dim"
23
+
24
+
25
+ def create_rich_theme(styles: UIStyles) -> Theme:
26
+ return Theme(
27
+ {
28
+ "success": styles.success,
29
+ "warning": styles.warning,
30
+ "error": styles.error,
31
+ "info": styles.info,
32
+ "muted": styles.muted,
33
+ "heading": styles.heading,
34
+ "command": styles.command,
35
+ "dry_run": styles.dry_run,
36
+ "path": styles.path,
37
+ "line_number": styles.line_number,
38
+ "border": styles.border,
39
+ }
40
+ )
41
+
42
+
43
+ THEMES = {
44
+ "standard": UIStyles(),
45
+ "monochrome": UIStyles(
46
+ success="bold white",
47
+ warning="white",
48
+ error="bold white",
49
+ info="white",
50
+ muted="dim",
51
+ heading="bold",
52
+ command="bold",
53
+ dry_run="bold",
54
+ path="white",
55
+ ),
56
+ "vibrant": UIStyles(
57
+ success="bold bright_green",
58
+ warning="bold bright_yellow",
59
+ error="bold bright_red",
60
+ info="bold bright_blue",
61
+ command="bold bright_magenta",
62
+ dry_run="bold bright_yellow",
63
+ path="bright_cyan",
64
+ ),
65
+ "ocean": UIStyles(
66
+ success="green",
67
+ warning="yellow",
68
+ error="red",
69
+ info="blue",
70
+ command="cyan",
71
+ dry_run="bright_blue",
72
+ path="bright_cyan",
73
+ heading="bold blue",
74
+ ),
75
+ "dracula": UIStyles(
76
+ success="#50fa7b",
77
+ warning="#f1fa8c",
78
+ error="#ff5555",
79
+ info="#8be9fd",
80
+ command="#bd93f9",
81
+ dry_run="#f1fa8c",
82
+ path="#8be9fd",
83
+ heading="bold #ff79c6",
84
+ muted="#6272a4",
85
+ border="#6272a4",
86
+ ),
87
+ }
88
+
89
+
90
+ @dataclass(slots=True, frozen=True)
91
+ class SettingDefinition:
92
+ key: str
93
+ description: str
94
+ kind: str
95
+ default: str | bool
96
+ choices: tuple[str, ...] = ()
97
+
98
+
99
+ SETTING_DEFINITIONS = {
100
+ "theme": SettingDefinition(
101
+ key="theme",
102
+ description="Terminal theme for Rich output.",
103
+ kind="choice",
104
+ default="standard",
105
+ choices=tuple(THEMES),
106
+ ),
107
+ "approval_mode": SettingDefinition(
108
+ key="approval_mode",
109
+ description="Default approval mode for chat tool calls.",
110
+ kind="choice",
111
+ default="ask",
112
+ choices=("ask", "auto-readonly", "auto-all"),
113
+ ),
114
+ "startup_help": SettingDefinition(
115
+ key="startup_help",
116
+ description="Show slash-command guidance when chat starts.",
117
+ kind="bool",
118
+ default=True,
119
+ ),
120
+ }
121
+
122
+
123
+ def _coerce_bool(value: object) -> bool:
124
+ if isinstance(value, bool):
125
+ return value
126
+ if isinstance(value, str):
127
+ normalized = value.strip().lower()
128
+ if normalized in {"1", "true", "t", "yes", "y", "on"}:
129
+ return True
130
+ if normalized in {"0", "false", "f", "no", "n", "off"}:
131
+ return False
132
+ raise ValueError("Expected a boolean value: true/false, yes/no, or on/off.")
133
+
134
+
135
+ class SettingsManager:
136
+ _instance: SettingsManager | None = None
137
+ _current_theme_name: str = "standard"
138
+ _approval_mode_name: str = "ask"
139
+ _startup_help: bool = True
140
+ _config_path: Path = Path.home() / ".rpr" / "settings.json"
141
+
142
+ def __new__(cls):
143
+ if cls._instance is None:
144
+ cls._instance = super(SettingsManager, cls).__new__(cls)
145
+ cls._instance._load_settings()
146
+ return cls._instance
147
+
148
+ @staticmethod
149
+ def definitions() -> dict[str, SettingDefinition]:
150
+ return SETTING_DEFINITIONS
151
+
152
+ def _default_data(self) -> dict[str, str | bool]:
153
+ return {
154
+ key: definition.default for key, definition in SETTING_DEFINITIONS.items()
155
+ }
156
+
157
+ def _load_settings(self):
158
+ data = self._default_data()
159
+ if self._config_path.exists():
160
+ try:
161
+ loaded = json.loads(self._config_path.read_text())
162
+ except json.JSONDecodeError, OSError:
163
+ loaded = {}
164
+ if isinstance(loaded, dict):
165
+ data.update(loaded)
166
+
167
+ self._current_theme_name = self._coerce_value(
168
+ "theme", data.get("theme", "standard")
169
+ )
170
+ self._approval_mode_name = self._coerce_value(
171
+ "approval_mode",
172
+ data.get("approval_mode", "ask"),
173
+ )
174
+ self._startup_help = self._coerce_value(
175
+ "startup_help", data.get("startup_help", True)
176
+ )
177
+
178
+ def _save_settings(self):
179
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
180
+ try:
181
+ self._config_path.write_text(
182
+ json.dumps(
183
+ {
184
+ "theme": self._current_theme_name,
185
+ "approval_mode": self._approval_mode_name,
186
+ "startup_help": self._startup_help,
187
+ },
188
+ indent=2,
189
+ )
190
+ )
191
+ except OSError:
192
+ pass
193
+
194
+ def _coerce_value(self, key: str, value: object) -> str | bool:
195
+ definition = SETTING_DEFINITIONS[key]
196
+ if definition.kind == "bool":
197
+ return _coerce_bool(value)
198
+
199
+ if not isinstance(value, str):
200
+ raise ValueError(f"Expected a string value for {key}.")
201
+
202
+ if definition.choices and value not in definition.choices:
203
+ allowed = ", ".join(definition.choices)
204
+ if key == "theme":
205
+ raise ValueError(f"Unknown theme: {value}")
206
+ raise ValueError(f"Unknown value for {key}: {value}. Valid: {allowed}")
207
+ return value
208
+
209
+ def get_value(self, key: str) -> str | bool:
210
+ if key not in SETTING_DEFINITIONS:
211
+ raise KeyError(key)
212
+ if key == "theme":
213
+ return self._current_theme_name
214
+ if key == "approval_mode":
215
+ return self._approval_mode_name
216
+ if key == "startup_help":
217
+ return self._startup_help
218
+ raise KeyError(key)
219
+
220
+ def set_value(self, key: str, value: object) -> str | bool:
221
+ if key not in SETTING_DEFINITIONS:
222
+ raise KeyError(key)
223
+
224
+ coerced = self._coerce_value(key, value)
225
+ if key == "theme":
226
+ self._current_theme_name = coerced
227
+ elif key == "approval_mode":
228
+ self._approval_mode_name = coerced
229
+ elif key == "startup_help":
230
+ self._startup_help = coerced
231
+ else:
232
+ raise KeyError(key)
233
+
234
+ self._save_settings()
235
+ return coerced
236
+
237
+ def describe_settings(self) -> list[tuple[SettingDefinition, str]]:
238
+ rows: list[tuple[SettingDefinition, str]] = []
239
+ for key, definition in SETTING_DEFINITIONS.items():
240
+ value = self.get_value(key)
241
+ if isinstance(value, bool):
242
+ display = "true" if value else "false"
243
+ else:
244
+ display = value
245
+ rows.append((definition, display))
246
+ return rows
247
+
248
+ @property
249
+ def current_theme_name(self) -> str:
250
+ return self._current_theme_name
251
+
252
+ @current_theme_name.setter
253
+ def current_theme_name(self, name: str):
254
+ self.set_value("theme", name)
255
+
256
+ @property
257
+ def approval_mode_name(self) -> str:
258
+ return self._approval_mode_name
259
+
260
+ @approval_mode_name.setter
261
+ def approval_mode_name(self, name: str) -> None:
262
+ self.set_value("approval_mode", name)
263
+
264
+ @property
265
+ def approval_mode(self):
266
+ from rpr.agent.approval import ApprovalMode
267
+
268
+ return ApprovalMode(self._approval_mode_name)
269
+
270
+ @property
271
+ def startup_help(self) -> bool:
272
+ return self._startup_help
273
+
274
+ @startup_help.setter
275
+ def startup_help(self, enabled: bool) -> None:
276
+ self.set_value("startup_help", enabled)
277
+
278
+ def get_theme(self) -> Theme:
279
+ return create_rich_theme(THEMES[self._current_theme_name])
280
+
281
+ def get_styles(self) -> UIStyles:
282
+ return THEMES[self._current_theme_name]
283
+
284
+
285
+ settings_manager = SettingsManager()
286
+ theme_manager = settings_manager
rpr/workspace.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from rpr.context import RunContext
10
+
11
+ console = Console()
12
+ DEFAULT_NPM_WORKSPACES = ["apps/*", "packages/node/*"]
13
+
14
+
15
+ def add_workspace_member(ctx: RunContext, member: str) -> None:
16
+ """Add a UV workspace member to the root pyproject.toml."""
17
+ pyproject_path = ctx.project_dir / "pyproject.toml"
18
+ if ctx.dry_run:
19
+ console.print(
20
+ f"[bold yellow][DRY RUN][/] Would add '{member}' to workspace members in {pyproject_path}",
21
+ soft_wrap=True,
22
+ )
23
+ return
24
+
25
+ import tomllib
26
+
27
+ with open(pyproject_path, "rb") as f:
28
+ data = tomllib.load(f)
29
+
30
+ members = data.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
31
+ if member in members:
32
+ return
33
+
34
+ members.append(member)
35
+ members_str = ", ".join(f'"{m}"' for m in members)
36
+ new_members_line = f"members = [{members_str}]"
37
+
38
+ content = pyproject_path.read_text()
39
+ content = re.sub(
40
+ r"members\s*=\s*\[.*?\]", new_members_line, content, flags=re.DOTALL
41
+ )
42
+ pyproject_path.write_text(content)
43
+ console.print(f"[bold green]Updated:[/] Added '{member}' to workspace members")
44
+
45
+
46
+ def derive_variables(project_name: str) -> dict[str, str]:
47
+ """Derive the four canonical token variants from the raw project name."""
48
+ name_underscore = project_name.replace("-", "_")
49
+ pascal = "".join(w.capitalize() for w in re.split(r"[-_]+", project_name) if w)
50
+ return {
51
+ "name": project_name,
52
+ "name-of-app": project_name,
53
+ "name_underscore": name_underscore,
54
+ "NamePascal": pascal,
55
+ }
56
+
57
+
58
+ def write_package_json(
59
+ ctx: RunContext,
60
+ path: Path,
61
+ *,
62
+ defaults: dict[str, object] | None = None,
63
+ dependencies: dict[str, str] | None = None,
64
+ dev_dependencies: dict[str, str] | None = None,
65
+ ) -> None:
66
+ """Create or update a package.json while preserving existing package pins."""
67
+ package: dict[str, object]
68
+ existed = path.exists()
69
+
70
+ if existed:
71
+ try:
72
+ package = json.loads(path.read_text())
73
+ except json.JSONDecodeError:
74
+ package = {}
75
+ else:
76
+ package = {}
77
+
78
+ for key, value in (defaults or {}).items():
79
+ package.setdefault(key, value)
80
+
81
+ for field_name, additions in (
82
+ ("dependencies", dependencies or {}),
83
+ ("devDependencies", dev_dependencies or {}),
84
+ ):
85
+ field = package.setdefault(field_name, {})
86
+ if not isinstance(field, dict):
87
+ field = {}
88
+ package[field_name] = field
89
+ for dep_name, version in additions.items():
90
+ field.setdefault(dep_name, version)
91
+
92
+ content = json.dumps(package, indent=2) + "\n"
93
+ if existed:
94
+ ctx.update_file(path, content)
95
+ else:
96
+ ctx.write_file(path, content)
97
+
98
+
99
+ def ensure_npm_workspaces(ctx: RunContext, *, package_name: str | None = None) -> None:
100
+ """Ensure the root package.json declares the standard npm workspace globs."""
101
+ package_path = ctx.project_dir / "package.json"
102
+ package: dict[str, object]
103
+ existed = package_path.exists()
104
+
105
+ if existed:
106
+ try:
107
+ package = json.loads(package_path.read_text())
108
+ except json.JSONDecodeError:
109
+ package = {}
110
+ else:
111
+ package = {}
112
+
113
+ if package_name:
114
+ package.setdefault("name", package_name)
115
+ package["private"] = True
116
+
117
+ workspaces = package.get("workspaces", [])
118
+ if not isinstance(workspaces, list):
119
+ workspaces = []
120
+
121
+ for workspace in DEFAULT_NPM_WORKSPACES:
122
+ if workspace not in workspaces:
123
+ workspaces.append(workspace)
124
+
125
+ package["workspaces"] = workspaces
126
+
127
+ content = json.dumps(package, indent=2) + "\n"
128
+ if existed:
129
+ ctx.update_file(package_path, content)
130
+ else:
131
+ ctx.write_file(package_path, content)
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: rpr-cli
3
+ Version: 0.1.1
4
+ Summary: RPR CLI -- React Python Rust monorepo scaffolding orchestrator
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: jinja2>=3.1.5
9
+ Requires-Dist: pathspec>=1.0.4
10
+ Requires-Dist: prompt-toolkit>=3.0.52
11
+ Requires-Dist: pyyaml>=6.0.2
12
+ Requires-Dist: rich>=14.3.3
13
+ Requires-Dist: typer>=0.24.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # RPR CLI
17
+
18
+ React Python Rust monorepo scaffolding orchestrator.
19
+
20
+ ## Installation
21
+
22
+ Published package name: `rpr-cli`
23
+
24
+ Once published to PyPI, install by name with either:
25
+
26
+ ```bash
27
+ uv add rpr-cli
28
+ uv tool install rpr-cli
29
+ ```
30
+
31
+ Both install the `rpr` command.
32
+
33
+ For development, editable install is fine:
34
+
35
+ ```bash
36
+ uv tool install -e .
37
+ ```
38
+
39
+ For an actual distributable install you can use in other projects, build the wheel first and install the built artifact:
40
+
41
+ ```bash
42
+ uv build
43
+ uv tool install dist/rpr_cli-*.whl
44
+ ```
45
+
46
+ From another local project you can also install the built artifact directly:
47
+
48
+ ```bash
49
+ uv add ../project-getting-started/dist/rpr_cli-*.whl
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### 🚀 Scaffolding & Generation
55
+
56
+ Scaffold a complete monorepo or add new apps and libraries.
57
+
58
+ ```bash
59
+ rpr init my-app # Scaffold a new monorepo (NX + UV)
60
+ rpr generate domain my-app # Add a domain package (shared logic & types)
61
+ rpr generate api my-app # Add a FastAPI application
62
+ rpr generate ui my-app # Add a React UI library + application
63
+ rpr generate engine my-app # Add a Rust/PyO3 computation engine
64
+ rpr generate storybook # Add Storybook for UI component development
65
+ ```
66
+
67
+ ### 🧠 AI Instructions & Code Templates
68
+
69
+ Sync project context to your favorite AI tools and add common code patterns.
70
+
71
+ ```bash
72
+ rpr sync rules # Sync AI instructions to Cursor, Copilot, Claude Code, and Gemini
73
+ rpr add template mapper_util # Add a common code template to your project
74
+ rpr add template --list # List all available code templates
75
+ ```
76
+
77
+ ### 💬 Interactive Shell
78
+
79
+ `rpr chat` is the primary interactive shell for the product. It runs a Rich-based session with slash commands for both chat controls and existing CLI workflows.
80
+
81
+ ```bash
82
+ rpr chat # Start the interactive shell
83
+ rpr chat --approval-mode auto-readonly # Override the session approval mode
84
+ ```
85
+
86
+ Inside the shell:
87
+
88
+ ```text
89
+ /help List supported slash commands
90
+ /settings Inspect and update persisted settings in-shell
91
+ /init Run workspace bootstrap from chat
92
+ /generate Run scaffolding commands from chat
93
+ /sync Sync AI instructions from chat
94
+ /add Add a registered template from chat
95
+ /check Run workspace health checks from chat
96
+ /map Generate a code map from chat
97
+ /exit End the session
98
+ ```
99
+
100
+ There is no separate `--tui` mode. The unified shell is the default chat experience.
101
+
102
+ ### 🛠️ Maintenance & Insights
103
+
104
+ Keep your project healthy and explore its structure.
105
+
106
+ ```bash
107
+ rpr check # Inspect project health and report setup status
108
+ rpr map # Generate a structured code map of your project
109
+ rpr settings # Inspect persisted shell settings
110
+ rpr settings set theme ocean # Update the terminal theme
111
+ rpr settings set approval_mode auto-all
112
+ rpr settings set startup_help false
113
+ ```
114
+
115
+ Persisted settings currently include:
116
+
117
+ - `theme`: Rich console theme
118
+ - `approval_mode`: default chat approval mode (`ask`, `auto-readonly`, `auto-all`)
119
+ - `startup_help`: whether chat shows slash-command guidance on startup
120
+
121
+ ### ⚙️ Common Flags
122
+
123
+ Most commands support these global flags:
124
+
125
+ - `--dry-run`: Preview changes without writing any files.
126
+ - `--help`: Show detailed help for any command or subcommand.
127
+
128
+ Example:
129
+
130
+ ```bash
131
+ rpr generate api my-app --dry-run
132
+ ```
133
+
134
+ The `check` command also supports:
135
+
136
+ - `--json`: Output machine-readable JSON for CI/CD integration.
137
+
138
+ The `chat` command also supports:
139
+
140
+ - `--provider`: Override the AI provider. The mock provider is the only supported provider today.
141
+ - `--model`: Override the active model name.
142
+ - `--approval-mode`: Override the session approval mode without changing persisted settings.
143
+
144
+ ### Project Development
145
+
146
+ #### Setup
147
+
148
+ ```bash
149
+ # Install Python dependencies
150
+ uv sync
151
+
152
+ # Build the Rust extension (rpr_parser) — required for rpr map acceleration
153
+ cd rpr-parser && uv run maturin develop && cd ..
154
+ ```
155
+
156
+ > **Note:** `uv tool install dist/rpr-*.whl` uses the root hatchling build backend and does **not** build the
157
+ > Rust extension. You must run `maturin develop` separately before using `rpr map` with the Rust
158
+ > backend. Without it, `rpr map` falls back to the pure-Python implementation automatically.
159
+
160
+ #### Running Tests
161
+
162
+ ```bash
163
+ # Full test suite (Python)
164
+ uv run pytest
165
+
166
+ # Single test file
167
+ uv run pytest tests/test_map_extractor.py
168
+
169
+ # Single test
170
+ uv run pytest tests/test_map_extractor.py::test_fallback_returns_correct_shape
171
+
172
+ # Rust unit tests (rpr_parser crate)
173
+ cd rpr-parser && cargo test && cd ..
174
+
175
+ # Lint and format
176
+ uv run ruff check .
177
+ uv run ruff format .
178
+ ```
179
+
180
+ The Rust-path tests in `tests/test_map_extractor.py` are automatically skipped if the Rust
181
+ extension hasn't been built yet — run `maturin develop` first to enable them.
182
+
183
+ ### Publishing To PyPI
184
+
185
+ This repo is set up to publish the package `rpr-cli` to PyPI from GitHub Actions.
186
+
187
+ One-time setup:
188
+
189
+ 1. Create the PyPI project `rpr-cli`.
190
+ 2. Add a repository secret named `PYPI_API_TOKEN` with an API token that can upload that project.
191
+
192
+ Release flow:
193
+
194
+ ```bash
195
+ # update src/rpr/__init__.py with the new version
196
+ git commit -am "Release 0.1.2"
197
+ git tag v0.1.2
198
+ git push origin main --tags
199
+ ```
200
+
201
+ The workflow in `.github/workflows/publish-pypi.yml` builds the sdist and wheel, checks that the tag matches the package version, and uploads `dist/*` to PyPI.