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
rpr/agent/session.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from rpr.agent.approval import ApprovalMode
|
|
10
|
+
from rpr.agent.client import ConversationMessage, MessageContent, ToolArguments
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolCallStatus(StrEnum):
|
|
14
|
+
PENDING = "pending"
|
|
15
|
+
APPROVED = "approved"
|
|
16
|
+
REJECTED = "rejected"
|
|
17
|
+
CANCELLED = "cancelled"
|
|
18
|
+
COMPLETED = "completed"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ToolCallRecord:
|
|
24
|
+
call_id: str
|
|
25
|
+
tool_name: str
|
|
26
|
+
arguments: ToolArguments
|
|
27
|
+
is_mutating: bool
|
|
28
|
+
status: ToolCallStatus = ToolCallStatus.PENDING
|
|
29
|
+
output: str | None = None
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ChatSession:
|
|
35
|
+
project_dir: Path
|
|
36
|
+
approval_mode: ApprovalMode = ApprovalMode.ASK
|
|
37
|
+
session_id: str = field(default_factory=lambda: uuid4().hex)
|
|
38
|
+
title: str = "New chat"
|
|
39
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
40
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
41
|
+
messages: list[ConversationMessage] = field(default_factory=list)
|
|
42
|
+
tool_calls: list[ToolCallRecord] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def add_user_message(self, content: MessageContent) -> None:
|
|
45
|
+
stripped = self._message_title_text(content).strip()
|
|
46
|
+
if stripped and self.title == "New chat":
|
|
47
|
+
self.title = stripped.splitlines()[0][:60]
|
|
48
|
+
self.touch()
|
|
49
|
+
self.messages.append(ConversationMessage(role="user", content=content))
|
|
50
|
+
|
|
51
|
+
def add_assistant_message(self, content: str) -> None:
|
|
52
|
+
if content:
|
|
53
|
+
self.touch()
|
|
54
|
+
self.messages.append(ConversationMessage(role="assistant", content=content))
|
|
55
|
+
|
|
56
|
+
def add_tool_message(self, tool_name: str, content: str) -> None:
|
|
57
|
+
self.touch()
|
|
58
|
+
self.messages.append(
|
|
59
|
+
ConversationMessage(role="tool", content=content, name=tool_name)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def create_tool_call(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
call_id: str,
|
|
66
|
+
tool_name: str,
|
|
67
|
+
arguments: ToolArguments,
|
|
68
|
+
is_mutating: bool,
|
|
69
|
+
) -> ToolCallRecord:
|
|
70
|
+
record = ToolCallRecord(
|
|
71
|
+
call_id=call_id,
|
|
72
|
+
tool_name=tool_name,
|
|
73
|
+
arguments=arguments,
|
|
74
|
+
is_mutating=is_mutating,
|
|
75
|
+
)
|
|
76
|
+
self.tool_calls.append(record)
|
|
77
|
+
self.touch()
|
|
78
|
+
return record
|
|
79
|
+
|
|
80
|
+
def enable_auto_all(self) -> None:
|
|
81
|
+
self.approval_mode = ApprovalMode.AUTO_ALL
|
|
82
|
+
self.touch()
|
|
83
|
+
|
|
84
|
+
def touch(self) -> None:
|
|
85
|
+
self.updated_at = datetime.now(UTC)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _message_title_text(content: MessageContent) -> str:
|
|
89
|
+
if isinstance(content, str):
|
|
90
|
+
return content
|
|
91
|
+
return "".join(part.text for part in content if hasattr(part, "text"))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from rpr.agent.tools.base import AgentTool, ToolResult, ToolSpec
|
|
2
|
+
from rpr.agent.tools.registry import ToolRegistry, build_default_tool_registry
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AgentTool",
|
|
6
|
+
"ToolRegistry",
|
|
7
|
+
"ToolResult",
|
|
8
|
+
"ToolSpec",
|
|
9
|
+
"build_default_tool_registry",
|
|
10
|
+
]
|
rpr/agent/tools/base.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rpr.agent.client import ToolArguments
|
|
8
|
+
from rpr.agent.control import TurnControl
|
|
9
|
+
from rpr.context import RunContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True, frozen=True)
|
|
13
|
+
class ToolSpec:
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
is_mutating: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True, frozen=True)
|
|
20
|
+
class ToolResult:
|
|
21
|
+
tool_name: str
|
|
22
|
+
success: bool
|
|
23
|
+
output: str
|
|
24
|
+
cancelled: bool = False
|
|
25
|
+
|
|
26
|
+
def as_message_content(self) -> str:
|
|
27
|
+
if self.cancelled:
|
|
28
|
+
status = "cancelled"
|
|
29
|
+
else:
|
|
30
|
+
status = "succeeded" if self.success else "failed"
|
|
31
|
+
return f"Tool {self.tool_name} {status}.\n\n{self.output}".strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentTool(ABC):
|
|
35
|
+
@property
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def spec(self) -> ToolSpec: ...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def execute(
|
|
41
|
+
self,
|
|
42
|
+
arguments: ToolArguments,
|
|
43
|
+
ctx: RunContext,
|
|
44
|
+
*,
|
|
45
|
+
control: TurnControl | None = None,
|
|
46
|
+
) -> ToolResult: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_project_path(project_dir: Path, raw_path: object) -> Path:
|
|
50
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
51
|
+
raise ValueError("Tool argument 'path' must be a non-empty string.")
|
|
52
|
+
|
|
53
|
+
candidate = Path(raw_path)
|
|
54
|
+
resolved = (
|
|
55
|
+
candidate.resolve()
|
|
56
|
+
if candidate.is_absolute()
|
|
57
|
+
else (project_dir / candidate).resolve()
|
|
58
|
+
)
|
|
59
|
+
project_root = project_dir.resolve()
|
|
60
|
+
if not resolved.is_relative_to(project_root):
|
|
61
|
+
raise ValueError(f"Path must stay within the project root: {project_root}")
|
|
62
|
+
return resolved
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rpr.agent.client import ToolArguments
|
|
4
|
+
from rpr.agent.control import TurnControl
|
|
5
|
+
from rpr.agent.tools.base import AgentTool, ToolResult, ToolSpec, resolve_project_path
|
|
6
|
+
from rpr.context import RunContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WriteFileTool(AgentTool):
|
|
10
|
+
@property
|
|
11
|
+
def spec(self) -> ToolSpec:
|
|
12
|
+
return ToolSpec(
|
|
13
|
+
name="write_file",
|
|
14
|
+
description="Create or update a file in the repository.",
|
|
15
|
+
is_mutating=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def execute(
|
|
19
|
+
self,
|
|
20
|
+
arguments: ToolArguments,
|
|
21
|
+
ctx: RunContext,
|
|
22
|
+
*,
|
|
23
|
+
control: TurnControl | None = None,
|
|
24
|
+
) -> ToolResult:
|
|
25
|
+
if control is not None and control.is_cancelled:
|
|
26
|
+
return ToolResult(
|
|
27
|
+
tool_name=self.spec.name,
|
|
28
|
+
success=False,
|
|
29
|
+
output="Tool execution cancelled.",
|
|
30
|
+
cancelled=True,
|
|
31
|
+
)
|
|
32
|
+
path = resolve_project_path(ctx.project_dir, arguments.get("path"))
|
|
33
|
+
content = arguments.get("content")
|
|
34
|
+
if not isinstance(content, str):
|
|
35
|
+
raise ValueError("Tool argument 'content' must be a string.")
|
|
36
|
+
|
|
37
|
+
if path.exists():
|
|
38
|
+
ctx.update_file(path, content)
|
|
39
|
+
else:
|
|
40
|
+
ctx.write_file(path, content)
|
|
41
|
+
|
|
42
|
+
relative_path = path.relative_to(ctx.project_dir)
|
|
43
|
+
return ToolResult(
|
|
44
|
+
tool_name=self.spec.name,
|
|
45
|
+
success=True,
|
|
46
|
+
output=f"Wrote {relative_path}",
|
|
47
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rpr.agent.client import ToolArguments
|
|
4
|
+
from rpr.agent.control import TurnControl
|
|
5
|
+
from rpr.agent.tools.base import AgentTool, ToolResult, ToolSpec, resolve_project_path
|
|
6
|
+
from rpr.context import RunContext
|
|
7
|
+
from rpr.map.walker import _IGNORED_DIRS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReadFileTool(AgentTool):
|
|
11
|
+
@property
|
|
12
|
+
def spec(self) -> ToolSpec:
|
|
13
|
+
return ToolSpec(
|
|
14
|
+
name="read_file", description="Read a file from the repository."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def execute(
|
|
18
|
+
self,
|
|
19
|
+
arguments: ToolArguments,
|
|
20
|
+
ctx: RunContext,
|
|
21
|
+
*,
|
|
22
|
+
control: TurnControl | None = None,
|
|
23
|
+
) -> ToolResult:
|
|
24
|
+
if control is not None and control.is_cancelled:
|
|
25
|
+
return ToolResult(
|
|
26
|
+
tool_name=self.spec.name,
|
|
27
|
+
success=False,
|
|
28
|
+
output="Tool execution cancelled.",
|
|
29
|
+
cancelled=True,
|
|
30
|
+
)
|
|
31
|
+
path = resolve_project_path(ctx.project_dir, arguments.get("path"))
|
|
32
|
+
start_line = int(arguments.get("start_line", 1))
|
|
33
|
+
end_line = arguments.get("end_line")
|
|
34
|
+
content = path.read_text()
|
|
35
|
+
lines = content.splitlines()
|
|
36
|
+
if end_line is None:
|
|
37
|
+
end_line = len(lines)
|
|
38
|
+
end_line = int(end_line)
|
|
39
|
+
selected = lines[max(start_line - 1, 0) : max(end_line, 0)]
|
|
40
|
+
output = "\n".join(
|
|
41
|
+
f"{index}: {line}" for index, line in enumerate(selected, start=start_line)
|
|
42
|
+
)
|
|
43
|
+
return ToolResult(
|
|
44
|
+
tool_name=self.spec.name,
|
|
45
|
+
success=True,
|
|
46
|
+
output=output or "(file is empty)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ListDirectoryTool(AgentTool):
|
|
51
|
+
@property
|
|
52
|
+
def spec(self) -> ToolSpec:
|
|
53
|
+
return ToolSpec(
|
|
54
|
+
name="list_directory", description="List files under a directory."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def execute(
|
|
58
|
+
self,
|
|
59
|
+
arguments: ToolArguments,
|
|
60
|
+
ctx: RunContext,
|
|
61
|
+
*,
|
|
62
|
+
control: TurnControl | None = None,
|
|
63
|
+
) -> ToolResult:
|
|
64
|
+
if control is not None and control.is_cancelled:
|
|
65
|
+
return ToolResult(
|
|
66
|
+
tool_name=self.spec.name,
|
|
67
|
+
success=False,
|
|
68
|
+
output="Tool execution cancelled.",
|
|
69
|
+
cancelled=True,
|
|
70
|
+
)
|
|
71
|
+
path = resolve_project_path(ctx.project_dir, arguments.get("path", "."))
|
|
72
|
+
entries = sorted(
|
|
73
|
+
path.iterdir(), key=lambda entry: (not entry.is_dir(), entry.name.lower())
|
|
74
|
+
)
|
|
75
|
+
output = "\n".join(
|
|
76
|
+
f"{entry.name}/" if entry.is_dir() else entry.name for entry in entries
|
|
77
|
+
)
|
|
78
|
+
return ToolResult(
|
|
79
|
+
tool_name=self.spec.name,
|
|
80
|
+
success=True,
|
|
81
|
+
output=output or "(directory is empty)",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SearchTextTool(AgentTool):
|
|
86
|
+
@property
|
|
87
|
+
def spec(self) -> ToolSpec:
|
|
88
|
+
return ToolSpec(
|
|
89
|
+
name="search_text", description="Search repository text for a query."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def execute(
|
|
93
|
+
self,
|
|
94
|
+
arguments: ToolArguments,
|
|
95
|
+
ctx: RunContext,
|
|
96
|
+
*,
|
|
97
|
+
control: TurnControl | None = None,
|
|
98
|
+
) -> ToolResult:
|
|
99
|
+
query = arguments.get("query")
|
|
100
|
+
if not isinstance(query, str) or not query.strip():
|
|
101
|
+
raise ValueError("Tool argument 'query' must be a non-empty string.")
|
|
102
|
+
|
|
103
|
+
max_results = int(arguments.get("max_results", 20))
|
|
104
|
+
normalized_query = query.casefold()
|
|
105
|
+
matches: list[str] = []
|
|
106
|
+
for current_root, dir_names, file_names in ctx.project_dir.walk():
|
|
107
|
+
if control is not None and control.is_cancelled:
|
|
108
|
+
return ToolResult(
|
|
109
|
+
tool_name=self.spec.name,
|
|
110
|
+
success=False,
|
|
111
|
+
output="Tool execution cancelled.",
|
|
112
|
+
cancelled=True,
|
|
113
|
+
)
|
|
114
|
+
dir_names[:] = [name for name in dir_names if name not in _IGNORED_DIRS]
|
|
115
|
+
for file_name in sorted(file_names):
|
|
116
|
+
if control is not None and control.is_cancelled:
|
|
117
|
+
return ToolResult(
|
|
118
|
+
tool_name=self.spec.name,
|
|
119
|
+
success=False,
|
|
120
|
+
output="Tool execution cancelled.",
|
|
121
|
+
cancelled=True,
|
|
122
|
+
)
|
|
123
|
+
path = current_root / file_name
|
|
124
|
+
try:
|
|
125
|
+
content = path.read_text()
|
|
126
|
+
except OSError, UnicodeDecodeError:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
130
|
+
if control is not None and control.is_cancelled:
|
|
131
|
+
return ToolResult(
|
|
132
|
+
tool_name=self.spec.name,
|
|
133
|
+
success=False,
|
|
134
|
+
output="Tool execution cancelled.",
|
|
135
|
+
cancelled=True,
|
|
136
|
+
)
|
|
137
|
+
if normalized_query in line.casefold():
|
|
138
|
+
relative_path = path.relative_to(ctx.project_dir)
|
|
139
|
+
matches.append(f"{relative_path}:{line_number}: {line.strip()}")
|
|
140
|
+
if len(matches) >= max_results:
|
|
141
|
+
output = "\n".join(matches)
|
|
142
|
+
return ToolResult(
|
|
143
|
+
tool_name=self.spec.name, success=True, output=output
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
output = "\n".join(matches) if matches else f"No matches found for: {query}"
|
|
147
|
+
return ToolResult(tool_name=self.spec.name, success=True, output=output)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from rpr.agent.tools.base import AgentTool
|
|
6
|
+
from rpr.agent.tools.mutating import WriteFileTool
|
|
7
|
+
from rpr.agent.tools.readonly import ListDirectoryTool, ReadFileTool, SearchTextTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class ToolRegistry:
|
|
12
|
+
tools: dict[str, AgentTool] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
def register(self, tool: AgentTool) -> None:
|
|
15
|
+
self.tools[tool.spec.name] = tool
|
|
16
|
+
|
|
17
|
+
def get(self, name: str) -> AgentTool | None:
|
|
18
|
+
return self.tools.get(name)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_default_tool_registry() -> ToolRegistry:
|
|
22
|
+
registry = ToolRegistry()
|
|
23
|
+
for tool in (
|
|
24
|
+
ReadFileTool(),
|
|
25
|
+
ListDirectoryTool(),
|
|
26
|
+
SearchTextTool(),
|
|
27
|
+
WriteFileTool(),
|
|
28
|
+
):
|
|
29
|
+
registry.register(tool)
|
|
30
|
+
return registry
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|