codemaster-cli 2.2.0__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 (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from typing import ClassVar
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from vibe.core.agent_loop import AgentLoop
9
+ from vibe.core.agents.models import AgentType
10
+ from vibe.core.config import SessionLoggingConfig, VibeConfig
11
+ from vibe.core.tools.base import (
12
+ BaseTool,
13
+ BaseToolConfig,
14
+ BaseToolState,
15
+ InvokeContext,
16
+ ToolError,
17
+ ToolPermission,
18
+ )
19
+ from vibe.core.tools.ui import (
20
+ ToolCallDisplay,
21
+ ToolResultDisplay,
22
+ ToolUIData,
23
+ ToolUIDataAdapter,
24
+ )
25
+ from vibe.core.types import (
26
+ AssistantEvent,
27
+ Role,
28
+ ToolCallEvent,
29
+ ToolResultEvent,
30
+ ToolStreamEvent,
31
+ )
32
+
33
+
34
+ class TaskArgs(BaseModel):
35
+ task: str = Field(description="The task to delegate to the subagent")
36
+ agent: str = Field(
37
+ default="explore",
38
+ description="Name of the agent profile to use (must be a subagent)",
39
+ )
40
+
41
+
42
+ class TaskResult(BaseModel):
43
+ response: str = Field(description="The accumulated response from the subagent")
44
+ turns_used: int = Field(description="Number of turns the subagent used")
45
+ completed: bool = Field(description="Whether the task completed normally")
46
+
47
+
48
+ class TaskToolConfig(BaseToolConfig):
49
+ permission: ToolPermission = ToolPermission.ASK
50
+
51
+
52
+ class Task(
53
+ BaseTool[TaskArgs, TaskResult, TaskToolConfig, BaseToolState],
54
+ ToolUIData[TaskArgs, TaskResult],
55
+ ):
56
+ description: ClassVar[str] = (
57
+ "Delegate a task to a subagent for independent execution. "
58
+ "Useful for exploration, research, or parallel work that doesn't "
59
+ "require user interaction. The subagent runs in-memory without "
60
+ "saving interaction logs."
61
+ )
62
+
63
+ @classmethod
64
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
65
+ args = event.args
66
+ if isinstance(args, TaskArgs):
67
+ return ToolCallDisplay(summary=f"Running {args.agent} agent: {args.task}")
68
+ return ToolCallDisplay(summary="Running subagent")
69
+
70
+ @classmethod
71
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
72
+ result = event.result
73
+ if isinstance(result, TaskResult):
74
+ turn_word = "turn" if result.turns_used == 1 else "turns"
75
+ if not result.completed:
76
+ return ToolResultDisplay(
77
+ success=False,
78
+ message=f"Agent interrupted after {result.turns_used} {turn_word}",
79
+ )
80
+ return ToolResultDisplay(
81
+ success=True,
82
+ message=f"Agent completed in {result.turns_used} {turn_word}",
83
+ )
84
+ return ToolResultDisplay(success=True, message="Agent completed")
85
+
86
+ @classmethod
87
+ def get_status_text(cls) -> str:
88
+ return "Running subagent"
89
+
90
+ async def run(
91
+ self, args: TaskArgs, ctx: InvokeContext | None = None
92
+ ) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
93
+ if not ctx or not ctx.agent_manager:
94
+ raise ToolError("Task tool requires agent_manager in context")
95
+
96
+ agent_manager = ctx.agent_manager
97
+
98
+ try:
99
+ agent_profile = agent_manager.get_agent(args.agent)
100
+ except ValueError as e:
101
+ raise ToolError(f"Unknown agent: {args.agent}") from e
102
+
103
+ if agent_profile.agent_type != AgentType.SUBAGENT:
104
+ raise ToolError(
105
+ f"Agent '{args.agent}' is a {agent_profile.agent_type.value} agent. "
106
+ f"Only subagents can be used with the task tool. "
107
+ f"This is a security constraint to prevent recursive spawning."
108
+ )
109
+
110
+ base_config = VibeConfig.load(
111
+ session_logging=SessionLoggingConfig(enabled=False)
112
+ )
113
+ subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
114
+
115
+ if ctx and ctx.approval_callback:
116
+ subagent_loop.set_approval_callback(ctx.approval_callback)
117
+
118
+ accumulated_response: list[str] = []
119
+ completed = True
120
+ try:
121
+ async for event in subagent_loop.act(args.task):
122
+ if isinstance(event, AssistantEvent) and event.content:
123
+ accumulated_response.append(event.content)
124
+ if event.stopped_by_middleware:
125
+ completed = False
126
+ elif isinstance(event, ToolResultEvent):
127
+ if event.skipped:
128
+ completed = False
129
+ elif event.result and event.tool_class:
130
+ adapter = ToolUIDataAdapter(event.tool_class)
131
+ display = adapter.get_result_display(event)
132
+ message = f"{event.tool_name}: {display.message}"
133
+ yield ToolStreamEvent(
134
+ tool_name=self.get_name(),
135
+ message=message,
136
+ tool_call_id=ctx.tool_call_id,
137
+ )
138
+
139
+ turns_used = sum(
140
+ msg.role == Role.assistant for msg in subagent_loop.messages
141
+ )
142
+
143
+ except Exception as e:
144
+ completed = False
145
+ accumulated_response.append(f"\n[Subagent error: {e}]")
146
+ turns_used = sum(
147
+ msg.role == Role.assistant for msg in subagent_loop.messages
148
+ )
149
+
150
+ yield TaskResult(
151
+ response="".join(accumulated_response),
152
+ turns_used=turns_used,
153
+ completed=completed,
154
+ )
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from enum import StrEnum, auto
5
+ from typing import ClassVar
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from vibe.core.tools.base import (
10
+ BaseTool,
11
+ BaseToolConfig,
12
+ BaseToolState,
13
+ InvokeContext,
14
+ ToolError,
15
+ ToolPermission,
16
+ )
17
+ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
18
+ from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
19
+
20
+
21
+ class TodoStatus(StrEnum):
22
+ PENDING = auto()
23
+ IN_PROGRESS = auto()
24
+ COMPLETED = auto()
25
+ CANCELLED = auto()
26
+
27
+
28
+ class TodoPriority(StrEnum):
29
+ LOW = auto()
30
+ MEDIUM = auto()
31
+ HIGH = auto()
32
+
33
+
34
+ class TodoItem(BaseModel):
35
+ id: str
36
+ content: str
37
+ status: TodoStatus = TodoStatus.PENDING
38
+ priority: TodoPriority = TodoPriority.MEDIUM
39
+
40
+
41
+ class TodoArgs(BaseModel):
42
+ action: str = Field(description="Either 'read' or 'write'")
43
+ todos: list[TodoItem] | None = Field(
44
+ default=None, description="Complete list of todos when writing."
45
+ )
46
+
47
+
48
+ class TodoResult(BaseModel):
49
+ message: str
50
+ todos: list[TodoItem]
51
+ total_count: int
52
+
53
+
54
+ class TodoConfig(BaseToolConfig):
55
+ permission: ToolPermission = ToolPermission.ALWAYS
56
+ max_todos: int = 100
57
+
58
+
59
+ class TodoState(BaseToolState):
60
+ todos: list[TodoItem] = Field(default_factory=list)
61
+
62
+
63
+ class Todo(
64
+ BaseTool[TodoArgs, TodoResult, TodoConfig, TodoState],
65
+ ToolUIData[TodoArgs, TodoResult],
66
+ ):
67
+ description: ClassVar[str] = (
68
+ "Manage todos. Use action='read' to view, action='write' with complete list to update."
69
+ )
70
+
71
+ @classmethod
72
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
73
+ if not isinstance(event.args, TodoArgs):
74
+ return ToolCallDisplay(summary="Invalid arguments")
75
+
76
+ args = event.args
77
+
78
+ match args.action:
79
+ case "read":
80
+ return ToolCallDisplay(summary="Reading todos")
81
+ case "write":
82
+ count = len(args.todos) if args.todos else 0
83
+ return ToolCallDisplay(summary=f"Writing {count} todos")
84
+ case _:
85
+ return ToolCallDisplay(summary=f"Unknown action: {args.action}")
86
+
87
+ @classmethod
88
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
89
+ if not isinstance(event.result, TodoResult):
90
+ return ToolResultDisplay(success=True, message="Success")
91
+
92
+ result = event.result
93
+
94
+ return ToolResultDisplay(success=True, message=result.message)
95
+
96
+ @classmethod
97
+ def get_status_text(cls) -> str:
98
+ return "Managing todos"
99
+
100
+ async def run(
101
+ self, args: TodoArgs, ctx: InvokeContext | None = None
102
+ ) -> AsyncGenerator[ToolStreamEvent | TodoResult, None]:
103
+ match args.action:
104
+ case "read":
105
+ yield self._read_todos()
106
+ case "write":
107
+ yield self._write_todos(args.todos or [])
108
+ case _:
109
+ raise ToolError(
110
+ f"Invalid action '{args.action}'. Use 'read' or 'write'."
111
+ )
112
+
113
+ def _read_todos(self) -> TodoResult:
114
+ return TodoResult(
115
+ message=f"Retrieved {len(self.state.todos)} todos",
116
+ todos=self.state.todos,
117
+ total_count=len(self.state.todos),
118
+ )
119
+
120
+ def _write_todos(self, todos: list[TodoItem]) -> TodoResult:
121
+ if len(todos) > self.config.max_todos:
122
+ raise ToolError(f"Cannot store more than {self.config.max_todos} todos")
123
+
124
+ ids = [todo.id for todo in todos]
125
+ if len(ids) != len(set(ids)):
126
+ raise ToolError("Todo IDs must be unique")
127
+
128
+ self.state.todos = todos
129
+
130
+ return TodoResult(
131
+ message=f"Updated {len(todos)} todos",
132
+ todos=self.state.todos,
133
+ total_count=len(self.state.todos),
134
+ )
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from pathlib import Path
5
+ from typing import ClassVar, final
6
+
7
+ import anyio
8
+ from pydantic import BaseModel, Field
9
+
10
+ from vibe.core.tools.base import (
11
+ BaseTool,
12
+ BaseToolConfig,
13
+ BaseToolState,
14
+ InvokeContext,
15
+ ToolError,
16
+ ToolPermission,
17
+ )
18
+ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
19
+ from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
20
+
21
+
22
+ class WriteFileArgs(BaseModel):
23
+ path: str
24
+ content: str
25
+ overwrite: bool = Field(
26
+ default=False, description="Must be set to true to overwrite an existing file."
27
+ )
28
+
29
+
30
+ class WriteFileResult(BaseModel):
31
+ path: str
32
+ bytes_written: int
33
+ file_existed: bool
34
+ content: str
35
+
36
+
37
+ class WriteFileConfig(BaseToolConfig):
38
+ permission: ToolPermission = ToolPermission.ASK
39
+ max_write_bytes: int = 64_000
40
+ create_parent_dirs: bool = True
41
+
42
+
43
+ class WriteFileState(BaseToolState):
44
+ recently_written_files: list[str] = Field(default_factory=list)
45
+
46
+
47
+ class WriteFile(
48
+ BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, WriteFileState],
49
+ ToolUIData[WriteFileArgs, WriteFileResult],
50
+ ):
51
+ description: ClassVar[str] = (
52
+ "Create or overwrite a UTF-8 file. Fails if file exists unless 'overwrite=True'."
53
+ )
54
+
55
+ @classmethod
56
+ def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
57
+ if not isinstance(event.args, WriteFileArgs):
58
+ return ToolCallDisplay(summary="Invalid arguments")
59
+
60
+ args = event.args
61
+
62
+ return ToolCallDisplay(
63
+ summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
64
+ content=args.content,
65
+ )
66
+
67
+ @classmethod
68
+ def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
69
+ if isinstance(event.result, WriteFileResult):
70
+ action = "Overwritten" if event.result.file_existed else "Created"
71
+ return ToolResultDisplay(
72
+ success=True, message=f"{action} {Path(event.result.path).name}"
73
+ )
74
+
75
+ return ToolResultDisplay(success=True, message="File written")
76
+
77
+ @classmethod
78
+ def get_status_text(cls) -> str:
79
+ return "Writing file"
80
+
81
+ def check_allowlist_denylist(self, args: WriteFileArgs) -> ToolPermission | None:
82
+ import fnmatch
83
+
84
+ file_path = Path(args.path).expanduser()
85
+ if not file_path.is_absolute():
86
+ file_path = Path.cwd() / file_path
87
+ file_str = str(file_path)
88
+
89
+ for pattern in self.config.denylist:
90
+ if fnmatch.fnmatch(file_str, pattern):
91
+ return ToolPermission.NEVER
92
+
93
+ for pattern in self.config.allowlist:
94
+ if fnmatch.fnmatch(file_str, pattern):
95
+ return ToolPermission.ALWAYS
96
+
97
+ return None
98
+
99
+ @final
100
+ async def run(
101
+ self, args: WriteFileArgs, ctx: InvokeContext | None = None
102
+ ) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
103
+ file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
104
+
105
+ await self._write_file(args, file_path)
106
+
107
+ BUFFER_SIZE = 10
108
+ self.state.recently_written_files.append(str(file_path))
109
+ if len(self.state.recently_written_files) > BUFFER_SIZE:
110
+ self.state.recently_written_files.pop(0)
111
+
112
+ yield WriteFileResult(
113
+ path=str(file_path),
114
+ bytes_written=content_bytes,
115
+ file_existed=file_existed,
116
+ content=args.content,
117
+ )
118
+
119
+ def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]:
120
+ if not args.path.strip():
121
+ raise ToolError("Path cannot be empty")
122
+
123
+ content_bytes = len(args.content.encode("utf-8"))
124
+ if content_bytes > self.config.max_write_bytes:
125
+ raise ToolError(
126
+ f"Content exceeds {self.config.max_write_bytes} bytes limit"
127
+ )
128
+
129
+ file_path = Path(args.path).expanduser()
130
+ if not file_path.is_absolute():
131
+ file_path = Path.cwd() / file_path
132
+ file_path = file_path.resolve()
133
+
134
+ try:
135
+ file_path.relative_to(Path.cwd().resolve())
136
+ except ValueError:
137
+ raise ToolError(f"Cannot write outside project directory: {file_path}")
138
+
139
+ file_existed = file_path.exists()
140
+
141
+ if file_existed and not args.overwrite:
142
+ raise ToolError(
143
+ f"File '{file_path}' exists. Set overwrite=True to replace."
144
+ )
145
+
146
+ if self.config.create_parent_dirs:
147
+ file_path.parent.mkdir(parents=True, exist_ok=True)
148
+ elif not file_path.parent.exists():
149
+ raise ToolError(f"Parent directory does not exist: {file_path.parent}")
150
+
151
+ return file_path, file_existed, content_bytes
152
+
153
+ async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
154
+ try:
155
+ async with await anyio.Path(file_path).open(
156
+ mode="w", encoding="utf-8"
157
+ ) as f:
158
+ await f.write(args.content)
159
+ except Exception as e:
160
+ raise ToolError(f"Error writing {file_path}: {e}") from e