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.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import deque
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit import print_formatted_text
|
|
11
|
+
from prompt_toolkit.application.current import get_app_session
|
|
12
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
13
|
+
|
|
14
|
+
from rpr.agent.approval import ApprovalDecision, ApprovalMode
|
|
15
|
+
from rpr.agent.bootstrap import build_chat_runtime_bundle
|
|
16
|
+
from rpr.agent.client import AgentClient
|
|
17
|
+
from rpr.agent.control import TurnControl
|
|
18
|
+
from rpr.agent.runtime import RuntimeCallbacks
|
|
19
|
+
from rpr.agent.session import ToolCallRecord
|
|
20
|
+
from rpr.application.completer import (
|
|
21
|
+
FileContextCompleter,
|
|
22
|
+
MultiCompleter,
|
|
23
|
+
SlashCommandCandidate,
|
|
24
|
+
SlashCommandCompleter,
|
|
25
|
+
)
|
|
26
|
+
from rpr.application.prompt_service import PromptService
|
|
27
|
+
from rpr.application.shell import CommandShellService, ShellPresenter
|
|
28
|
+
from rpr.context import RunContext
|
|
29
|
+
from rpr.ui.console import console, refresh_console
|
|
30
|
+
from rpr.ui.renderers import (
|
|
31
|
+
render_approval_prompt,
|
|
32
|
+
render_chat_hint,
|
|
33
|
+
render_error,
|
|
34
|
+
render_heading,
|
|
35
|
+
render_info,
|
|
36
|
+
render_session_header,
|
|
37
|
+
render_status_row,
|
|
38
|
+
render_success,
|
|
39
|
+
render_table,
|
|
40
|
+
render_tool_call,
|
|
41
|
+
render_tool_result,
|
|
42
|
+
render_warning,
|
|
43
|
+
)
|
|
44
|
+
from rpr.ui.theme import SettingsManager
|
|
45
|
+
|
|
46
|
+
type AgentClientFactory = Callable[[str | None, str | None], AgentClient]
|
|
47
|
+
type ContextFactory = Callable[[], RunContext]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class AssistantStreamRenderer:
|
|
52
|
+
_accumulated: str = ""
|
|
53
|
+
_lines_printed: int = 0
|
|
54
|
+
|
|
55
|
+
def append(self, chunk: str) -> None:
|
|
56
|
+
self._accumulated += chunk
|
|
57
|
+
output = get_app_session().output
|
|
58
|
+
if self._lines_printed > 0:
|
|
59
|
+
output.cursor_up(self._lines_printed)
|
|
60
|
+
output.erase_down()
|
|
61
|
+
output.flush()
|
|
62
|
+
print_formatted_text(
|
|
63
|
+
FormattedText(
|
|
64
|
+
[
|
|
65
|
+
("", "\n"),
|
|
66
|
+
("bold fg:ansiblue", "Assistant >"),
|
|
67
|
+
("", "\n\n"),
|
|
68
|
+
("", self._accumulated),
|
|
69
|
+
]
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
# 1 (leading \n) + 1 ("Assistant >") + 2 (\n\n) + content newlines + 1 (end=\n)
|
|
73
|
+
self._lines_printed = 4 + self._accumulated.count("\n")
|
|
74
|
+
|
|
75
|
+
def flush(self) -> None:
|
|
76
|
+
self._accumulated = ""
|
|
77
|
+
self._lines_printed = 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class RichShellPresenter(ShellPresenter):
|
|
82
|
+
stream_renderer: AssistantStreamRenderer
|
|
83
|
+
|
|
84
|
+
def flush(self) -> None:
|
|
85
|
+
self.stream_renderer.flush()
|
|
86
|
+
|
|
87
|
+
def append_text(self, chunk: str) -> None:
|
|
88
|
+
self.stream_renderer.append(chunk)
|
|
89
|
+
|
|
90
|
+
def heading(self, text: str) -> None:
|
|
91
|
+
self.flush()
|
|
92
|
+
render_heading(text)
|
|
93
|
+
|
|
94
|
+
def info(self, message: str) -> None:
|
|
95
|
+
self.flush()
|
|
96
|
+
render_info(message)
|
|
97
|
+
|
|
98
|
+
def success(self, message: str) -> None:
|
|
99
|
+
self.flush()
|
|
100
|
+
render_success(message)
|
|
101
|
+
|
|
102
|
+
def warning(self, message: str) -> None:
|
|
103
|
+
self.flush()
|
|
104
|
+
render_warning(message)
|
|
105
|
+
|
|
106
|
+
def error(self, message: str) -> None:
|
|
107
|
+
self.flush()
|
|
108
|
+
render_error(message)
|
|
109
|
+
|
|
110
|
+
def table(self, title: str, columns: list[str], rows: list[list[str]]) -> None:
|
|
111
|
+
self.flush()
|
|
112
|
+
render_table(title, columns, rows)
|
|
113
|
+
|
|
114
|
+
def status_row(
|
|
115
|
+
self,
|
|
116
|
+
icon_char: str,
|
|
117
|
+
area: str,
|
|
118
|
+
path: str,
|
|
119
|
+
status: bool,
|
|
120
|
+
suggestion: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.flush()
|
|
123
|
+
render_status_row(icon_char, area, path, status, suggestion)
|
|
124
|
+
|
|
125
|
+
def on_setting_changed(self, key: str) -> None:
|
|
126
|
+
self.flush()
|
|
127
|
+
if key == "theme":
|
|
128
|
+
refresh_console()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ChatCommandService:
|
|
132
|
+
_instance: ClassVar[ChatCommandService | None] = None
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
self._prompt_service = PromptService.instance()
|
|
136
|
+
self._shell_service = CommandShellService.instance()
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def instance(cls) -> ChatCommandService:
|
|
140
|
+
if cls._instance is None:
|
|
141
|
+
cls._instance = cls()
|
|
142
|
+
return cls._instance
|
|
143
|
+
|
|
144
|
+
async def run(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
provider: str | None,
|
|
148
|
+
model: str | None,
|
|
149
|
+
approval_mode_override: str | None,
|
|
150
|
+
client_factory: AgentClientFactory,
|
|
151
|
+
context_factory: ContextFactory,
|
|
152
|
+
settings: SettingsManager,
|
|
153
|
+
) -> None:
|
|
154
|
+
ctx = context_factory()
|
|
155
|
+
client = client_factory(provider, model)
|
|
156
|
+
resolved_approval_mode = self._resolve_approval_mode(
|
|
157
|
+
approval_mode_override, settings
|
|
158
|
+
)
|
|
159
|
+
conversation = self._prompt_service.create_conversation(
|
|
160
|
+
project_dir=ctx.project_dir,
|
|
161
|
+
approval_mode=resolved_approval_mode,
|
|
162
|
+
)
|
|
163
|
+
presenter = RichShellPresenter(stream_renderer=AssistantStreamRenderer())
|
|
164
|
+
|
|
165
|
+
# Define these before bundle creation so the approval closure captures them.
|
|
166
|
+
message_queue: deque[str] = deque()
|
|
167
|
+
|
|
168
|
+
def _update_queue_display() -> None:
|
|
169
|
+
preview = message_queue[0] if message_queue else None
|
|
170
|
+
self._prompt_service.set_queue_info(len(message_queue), preview)
|
|
171
|
+
|
|
172
|
+
async def _request_approval(record: ToolCallRecord) -> ApprovalDecision:
|
|
173
|
+
presenter.flush()
|
|
174
|
+
render_approval_prompt(record.tool_name)
|
|
175
|
+
response = await self._prompt_service.read_input(
|
|
176
|
+
"Approve? [a]pprove/[r]eject/[s]ession > ",
|
|
177
|
+
modal=True,
|
|
178
|
+
)
|
|
179
|
+
normalized = response.lower()
|
|
180
|
+
if normalized in {"a", "approve"}:
|
|
181
|
+
return ApprovalDecision.APPROVE
|
|
182
|
+
if normalized in {"s", "session", "always"}:
|
|
183
|
+
return ApprovalDecision.ALWAYS_ALLOW
|
|
184
|
+
return ApprovalDecision.REJECT
|
|
185
|
+
|
|
186
|
+
bundle = self._build_runtime_bundle(
|
|
187
|
+
ctx=ctx,
|
|
188
|
+
client=client,
|
|
189
|
+
approval_mode=resolved_approval_mode,
|
|
190
|
+
presenter=presenter,
|
|
191
|
+
session=conversation,
|
|
192
|
+
request_approval=_request_approval,
|
|
193
|
+
)
|
|
194
|
+
self._shell_service.configure(
|
|
195
|
+
bundle=bundle,
|
|
196
|
+
presenter=presenter,
|
|
197
|
+
prompts=self._prompt_service,
|
|
198
|
+
)
|
|
199
|
+
candidates = [
|
|
200
|
+
SlashCommandCandidate(
|
|
201
|
+
name=spec.name,
|
|
202
|
+
description=spec.description,
|
|
203
|
+
usage=spec.usage,
|
|
204
|
+
)
|
|
205
|
+
for spec in self._shell_service.command_specs
|
|
206
|
+
]
|
|
207
|
+
self._prompt_service.set_completer(
|
|
208
|
+
MultiCompleter(
|
|
209
|
+
[
|
|
210
|
+
SlashCommandCompleter(candidates),
|
|
211
|
+
FileContextCompleter(ctx.project_dir),
|
|
212
|
+
]
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
render_session_header(
|
|
217
|
+
project_root=ctx.project_dir,
|
|
218
|
+
provider=client.provider_name,
|
|
219
|
+
model=client.model_name,
|
|
220
|
+
approval_mode=bundle.session.approval_mode.value,
|
|
221
|
+
)
|
|
222
|
+
if settings.startup_help:
|
|
223
|
+
render_chat_hint(
|
|
224
|
+
"Type /help to inspect slash commands and /exit to end the session."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
active_task: asyncio.Task | None = None
|
|
228
|
+
|
|
229
|
+
def _dispatch_agent_turn(message: str) -> None:
|
|
230
|
+
nonlocal active_task
|
|
231
|
+
control = TurnControl()
|
|
232
|
+
self._prompt_service.set_active_control(control)
|
|
233
|
+
|
|
234
|
+
async def _run_turn() -> None:
|
|
235
|
+
try:
|
|
236
|
+
await self._shell_service.handle_input(message, control=control)
|
|
237
|
+
presenter.flush()
|
|
238
|
+
self._prompt_service.save_conversation(bundle.session)
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
presenter.flush()
|
|
241
|
+
render_chat_hint("Response cancelled.")
|
|
242
|
+
raise
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
presenter.flush()
|
|
245
|
+
render_error(f"Error: {exc}")
|
|
246
|
+
|
|
247
|
+
task = asyncio.create_task(_run_turn())
|
|
248
|
+
active_task = task
|
|
249
|
+
self._prompt_service.set_active_task(task)
|
|
250
|
+
|
|
251
|
+
def _on_done(fut: asyncio.Task) -> None:
|
|
252
|
+
nonlocal active_task
|
|
253
|
+
active_task = None
|
|
254
|
+
self._prompt_service.set_active_task(None)
|
|
255
|
+
self._prompt_service.set_active_control(None)
|
|
256
|
+
if message_queue:
|
|
257
|
+
next_msg = message_queue.popleft()
|
|
258
|
+
_update_queue_display()
|
|
259
|
+
_dispatch_agent_turn(next_msg)
|
|
260
|
+
else:
|
|
261
|
+
_update_queue_display()
|
|
262
|
+
|
|
263
|
+
task.add_done_callback(_on_done)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
while True:
|
|
267
|
+
try:
|
|
268
|
+
if active_task is not None and not active_task.done():
|
|
269
|
+
await asyncio.sleep(0)
|
|
270
|
+
while (
|
|
271
|
+
active_task is not None
|
|
272
|
+
and not active_task.done()
|
|
273
|
+
and self._prompt_service.has_modal_input()
|
|
274
|
+
):
|
|
275
|
+
await asyncio.sleep(0.01)
|
|
276
|
+
|
|
277
|
+
user_input = await self._prompt_service.read_input(
|
|
278
|
+
"> ", multiline=True
|
|
279
|
+
)
|
|
280
|
+
if not user_input:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
normalized = user_input.strip()
|
|
284
|
+
is_slash = normalized.startswith("/") or normalized.lower() in {
|
|
285
|
+
"exit",
|
|
286
|
+
"quit",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if is_slash:
|
|
290
|
+
try:
|
|
291
|
+
should_continue = await self._shell_service.handle_input(
|
|
292
|
+
normalized
|
|
293
|
+
)
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
presenter.flush()
|
|
296
|
+
render_error(f"Error: {exc}")
|
|
297
|
+
else:
|
|
298
|
+
presenter.flush()
|
|
299
|
+
self._prompt_service.save_conversation(bundle.session)
|
|
300
|
+
if not should_continue:
|
|
301
|
+
if active_task is not None and not active_task.done():
|
|
302
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
303
|
+
await active_task
|
|
304
|
+
break
|
|
305
|
+
else:
|
|
306
|
+
if active_task is not None and not active_task.done():
|
|
307
|
+
message_queue.append(normalized)
|
|
308
|
+
_update_queue_display()
|
|
309
|
+
else:
|
|
310
|
+
_dispatch_agent_turn(normalized)
|
|
311
|
+
|
|
312
|
+
except KeyboardInterrupt, EOFError:
|
|
313
|
+
if active_task is not None and not active_task.done():
|
|
314
|
+
active_task.cancel()
|
|
315
|
+
presenter.flush()
|
|
316
|
+
render_chat_hint("Session ended.")
|
|
317
|
+
break
|
|
318
|
+
finally:
|
|
319
|
+
if active_task is not None and not active_task.done():
|
|
320
|
+
active_task.cancel()
|
|
321
|
+
with suppress(asyncio.CancelledError):
|
|
322
|
+
await active_task
|
|
323
|
+
presenter.flush()
|
|
324
|
+
self._prompt_service.save_conversation(bundle.session)
|
|
325
|
+
|
|
326
|
+
render_chat_hint("Goodbye!")
|
|
327
|
+
|
|
328
|
+
def _build_runtime_bundle(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
ctx: RunContext,
|
|
332
|
+
client: AgentClient,
|
|
333
|
+
approval_mode: ApprovalMode,
|
|
334
|
+
presenter: RichShellPresenter,
|
|
335
|
+
session,
|
|
336
|
+
request_approval=None,
|
|
337
|
+
):
|
|
338
|
+
callbacks = RuntimeCallbacks(
|
|
339
|
+
on_text_delta=presenter.append_text,
|
|
340
|
+
on_tool_call=lambda record: self._handle_tool_call_render(
|
|
341
|
+
record, presenter.stream_renderer
|
|
342
|
+
),
|
|
343
|
+
on_tool_result=lambda record, result: self._handle_tool_result_render(
|
|
344
|
+
record, result.output, result.success
|
|
345
|
+
),
|
|
346
|
+
request_approval=request_approval
|
|
347
|
+
or (lambda record: self._prompt_for_approval(record, presenter=presenter)),
|
|
348
|
+
on_attachment_notice=lambda message: self._handle_attachment_notice(
|
|
349
|
+
message, presenter.stream_renderer
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
return build_chat_runtime_bundle(
|
|
353
|
+
ctx=ctx,
|
|
354
|
+
client=client,
|
|
355
|
+
approval_mode=approval_mode,
|
|
356
|
+
callbacks=callbacks,
|
|
357
|
+
session=session,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _handle_tool_call_render(
|
|
361
|
+
self, record: ToolCallRecord, stream_renderer: AssistantStreamRenderer
|
|
362
|
+
) -> None:
|
|
363
|
+
stream_renderer.flush()
|
|
364
|
+
render_tool_call(
|
|
365
|
+
record.tool_name, dict(record.arguments), is_mutating=record.is_mutating
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _handle_tool_result_render(
|
|
370
|
+
record: ToolCallRecord, output: str, success: bool
|
|
371
|
+
) -> None:
|
|
372
|
+
render_tool_result(record.tool_name, output, success=success)
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _handle_attachment_notice(
|
|
376
|
+
message: str, stream_renderer: AssistantStreamRenderer
|
|
377
|
+
) -> None:
|
|
378
|
+
stream_renderer.flush()
|
|
379
|
+
render_chat_hint(message)
|
|
380
|
+
|
|
381
|
+
async def _prompt_for_approval(
|
|
382
|
+
self,
|
|
383
|
+
record: ToolCallRecord,
|
|
384
|
+
*,
|
|
385
|
+
presenter: RichShellPresenter,
|
|
386
|
+
) -> ApprovalDecision:
|
|
387
|
+
presenter.flush()
|
|
388
|
+
render_approval_prompt(record.tool_name)
|
|
389
|
+
response = await self._prompt_service.read_input(
|
|
390
|
+
"Approve? [a]pprove/[r]eject/[s]ession > ",
|
|
391
|
+
modal=True,
|
|
392
|
+
)
|
|
393
|
+
normalized = response.lower()
|
|
394
|
+
if normalized in {"a", "approve"}:
|
|
395
|
+
return ApprovalDecision.APPROVE
|
|
396
|
+
if normalized in {"s", "session", "always"}:
|
|
397
|
+
return ApprovalDecision.ALWAYS_ALLOW
|
|
398
|
+
return ApprovalDecision.REJECT
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _resolve_approval_mode(
|
|
402
|
+
approval_mode_override: str | None,
|
|
403
|
+
settings: SettingsManager,
|
|
404
|
+
) -> ApprovalMode:
|
|
405
|
+
if approval_mode_override is None:
|
|
406
|
+
return settings.approval_mode
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
return ApprovalMode(approval_mode_override.lower())
|
|
410
|
+
except ValueError as exc:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
"Approval mode must be ask, auto-readonly, or auto-all."
|
|
413
|
+
) from exc
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rpr.checks.instructions import check_instructions
|
|
7
|
+
from rpr.checks.packages import check_packages
|
|
8
|
+
from rpr.checks.workspace import check_workspace
|
|
9
|
+
from rpr.generators.base import SyncConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True, frozen=True)
|
|
13
|
+
class CheckReport:
|
|
14
|
+
project_name: str
|
|
15
|
+
results: list[object]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def collect_check_report(project_dir: Path, group: str | None = None) -> CheckReport:
|
|
19
|
+
config_path = project_dir / ".rpr.yaml"
|
|
20
|
+
config = SyncConfig.load(config_path)
|
|
21
|
+
|
|
22
|
+
variables = config.variables or {}
|
|
23
|
+
project_name = variables.get("name", "(unknown)")
|
|
24
|
+
requested_group = None if group is None else group.lower()
|
|
25
|
+
results: list[object] = []
|
|
26
|
+
|
|
27
|
+
if requested_group in {None, "workspace"}:
|
|
28
|
+
results.extend(check_workspace(project_dir))
|
|
29
|
+
|
|
30
|
+
if requested_group in {None, "instructions"}:
|
|
31
|
+
results.extend(check_instructions(project_dir, variables))
|
|
32
|
+
|
|
33
|
+
if requested_group in {None, "packages"}:
|
|
34
|
+
results.extend(check_packages(project_dir, variables))
|
|
35
|
+
|
|
36
|
+
return CheckReport(project_name=project_name, results=results)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def serialize_check_results(results: list[object]) -> list[dict[str, object]]:
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
"group": result.group,
|
|
43
|
+
"area": result.area,
|
|
44
|
+
"status": result.status,
|
|
45
|
+
"path": result.path,
|
|
46
|
+
"suggestion": result.suggestion,
|
|
47
|
+
}
|
|
48
|
+
for result in results
|
|
49
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rpr.application.catalog import COMMAND_CATALOG, CommandDefinition
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_cli_definition(action_id: str) -> CommandDefinition | None:
|
|
7
|
+
"""Look up a CommandDefinition by action_id across all CLI-enabled commands.
|
|
8
|
+
|
|
9
|
+
Searches recursively through the full catalog tree. Returns the first match
|
|
10
|
+
where ``cmd.cli_enabled`` is True and ``cmd.action_id == action_id``.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
get_cli_definition("generate.ui") # → CommandDefinition(name="ui", ...)
|
|
15
|
+
get_cli_definition("chat.exit") # → None (cli_enabled=False)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def _search(cmds: tuple[CommandDefinition, ...]) -> CommandDefinition | None:
|
|
19
|
+
for cmd in cmds:
|
|
20
|
+
if cmd.cli_enabled and cmd.action_id == action_id:
|
|
21
|
+
return cmd
|
|
22
|
+
if cmd.children:
|
|
23
|
+
found = _search(cmd.children)
|
|
24
|
+
if found is not None:
|
|
25
|
+
return found
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
return _search(COMMAND_CATALOG)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_root_command() -> CommandDefinition | None:
|
|
32
|
+
"""Return the top-level command flagged as default_root_command, if any."""
|
|
33
|
+
for cmd in COMMAND_CATALOG:
|
|
34
|
+
if cmd.default_root_command:
|
|
35
|
+
return cmd
|
|
36
|
+
return None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
8
|
+
from prompt_toolkit.document import Document
|
|
9
|
+
|
|
10
|
+
from rpr.application.selector import TriggerSelectorCandidate, TriggerSelectorSource
|
|
11
|
+
from rpr.map.walker import list_project_entries
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True, frozen=True)
|
|
15
|
+
class SlashCommandCandidate:
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
usage: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True, frozen=True)
|
|
22
|
+
class FileContextCandidate:
|
|
23
|
+
path: str
|
|
24
|
+
is_dir: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SlashCommandCompleter(Completer):
|
|
28
|
+
def __init__(self, candidates: Sequence[SlashCommandCandidate]) -> None:
|
|
29
|
+
self._candidates = candidates
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def selector_source(self) -> TriggerSelectorSource:
|
|
33
|
+
return TriggerSelectorSource(
|
|
34
|
+
trigger="/",
|
|
35
|
+
candidates=tuple(
|
|
36
|
+
TriggerSelectorCandidate(
|
|
37
|
+
value=candidate.name,
|
|
38
|
+
label=f"/{candidate.name}",
|
|
39
|
+
description=candidate.description,
|
|
40
|
+
insert_text=f"/{candidate.name} ",
|
|
41
|
+
detail=candidate.usage,
|
|
42
|
+
)
|
|
43
|
+
for candidate in self._candidates
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get_completions(
|
|
48
|
+
self,
|
|
49
|
+
document: Document,
|
|
50
|
+
complete_event: CompleteEvent,
|
|
51
|
+
) -> Iterable[Completion]:
|
|
52
|
+
text = document.text_before_cursor
|
|
53
|
+
if not text.startswith("/"):
|
|
54
|
+
return
|
|
55
|
+
partial = text[1:]
|
|
56
|
+
for candidate in self._candidates:
|
|
57
|
+
if candidate.name.startswith(partial):
|
|
58
|
+
yield Completion(
|
|
59
|
+
text=candidate.name,
|
|
60
|
+
start_position=-len(partial),
|
|
61
|
+
display=f"/{candidate.name}",
|
|
62
|
+
display_meta=candidate.description,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileContextCompleter(Completer):
|
|
67
|
+
def __init__(self, root: Path) -> None:
|
|
68
|
+
self._root = root
|
|
69
|
+
self._candidates = self._load_candidates()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def selector_source(self) -> TriggerSelectorSource:
|
|
73
|
+
return TriggerSelectorSource(
|
|
74
|
+
trigger="@",
|
|
75
|
+
anchored_to_start=False,
|
|
76
|
+
hide_on_exact_match=True,
|
|
77
|
+
case_sensitive=False,
|
|
78
|
+
candidates=tuple(
|
|
79
|
+
TriggerSelectorCandidate(
|
|
80
|
+
value=candidate.path,
|
|
81
|
+
label=candidate.path,
|
|
82
|
+
description="Directory" if candidate.is_dir else "File",
|
|
83
|
+
insert_text=f"@{candidate.path}",
|
|
84
|
+
detail=str(self._root / candidate.path.rstrip("/")),
|
|
85
|
+
keep_open_on_exact_match=candidate.is_dir,
|
|
86
|
+
)
|
|
87
|
+
for candidate in self._candidates
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def get_completions(
|
|
92
|
+
self,
|
|
93
|
+
document: Document,
|
|
94
|
+
complete_event: CompleteEvent,
|
|
95
|
+
) -> Iterable[Completion]:
|
|
96
|
+
del complete_event
|
|
97
|
+
query = self._extract_query(document.text_before_cursor)
|
|
98
|
+
if query is None:
|
|
99
|
+
return
|
|
100
|
+
normalized_query = query.casefold()
|
|
101
|
+
for candidate in self._candidates:
|
|
102
|
+
if candidate.path.casefold().startswith(normalized_query):
|
|
103
|
+
yield Completion(
|
|
104
|
+
text=candidate.path,
|
|
105
|
+
start_position=-len(query),
|
|
106
|
+
display=candidate.path,
|
|
107
|
+
display_meta="Directory" if candidate.is_dir else "File",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _load_candidates(self) -> tuple[FileContextCandidate, ...]:
|
|
111
|
+
entries = list_project_entries(self._root)
|
|
112
|
+
return tuple(
|
|
113
|
+
FileContextCandidate(
|
|
114
|
+
path=f"{entry.rel_path.as_posix()}/"
|
|
115
|
+
if entry.is_dir
|
|
116
|
+
else entry.rel_path.as_posix(),
|
|
117
|
+
is_dir=entry.is_dir,
|
|
118
|
+
)
|
|
119
|
+
for entry in entries
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _extract_query(text_before_cursor: str) -> str | None:
|
|
124
|
+
trigger_index = text_before_cursor.rfind("@")
|
|
125
|
+
if trigger_index < 0:
|
|
126
|
+
return None
|
|
127
|
+
if trigger_index > 0 and not text_before_cursor[trigger_index - 1].isspace():
|
|
128
|
+
return None
|
|
129
|
+
query = text_before_cursor[trigger_index + 1 :]
|
|
130
|
+
if any(character.isspace() for character in query):
|
|
131
|
+
return None
|
|
132
|
+
return query
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class MultiCompleter(Completer):
|
|
136
|
+
def __init__(self, completers: Sequence[Completer]) -> None:
|
|
137
|
+
self._completers = tuple(completers)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def selector_sources(self) -> tuple[TriggerSelectorSource, ...]:
|
|
141
|
+
sources: list[TriggerSelectorSource] = []
|
|
142
|
+
for completer in self._completers:
|
|
143
|
+
selector_sources = getattr(completer, "selector_sources", None)
|
|
144
|
+
if isinstance(selector_sources, tuple):
|
|
145
|
+
sources.extend(
|
|
146
|
+
source
|
|
147
|
+
for source in selector_sources
|
|
148
|
+
if isinstance(source, TriggerSelectorSource)
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
selector_source = getattr(completer, "selector_source", None)
|
|
153
|
+
if isinstance(selector_source, TriggerSelectorSource):
|
|
154
|
+
sources.append(selector_source)
|
|
155
|
+
return tuple(sources)
|
|
156
|
+
|
|
157
|
+
def get_completions(
|
|
158
|
+
self,
|
|
159
|
+
document: Document,
|
|
160
|
+
complete_event: CompleteEvent,
|
|
161
|
+
) -> Iterable[Completion]:
|
|
162
|
+
for completer in self._completers:
|
|
163
|
+
yield from completer.get_completions(document, complete_event)
|