klaude-code 1.2.9__py3-none-any.whl → 1.2.10__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 (55) hide show
  1. klaude_code/cli/main.py +12 -1
  2. klaude_code/cli/runtime.py +7 -11
  3. klaude_code/command/__init__.py +68 -23
  4. klaude_code/command/clear_cmd.py +6 -2
  5. klaude_code/command/command_abc.py +5 -2
  6. klaude_code/command/diff_cmd.py +5 -2
  7. klaude_code/command/export_cmd.py +7 -4
  8. klaude_code/command/help_cmd.py +6 -2
  9. klaude_code/command/model_cmd.py +5 -2
  10. klaude_code/command/prompt_command.py +8 -3
  11. klaude_code/command/refresh_cmd.py +6 -2
  12. klaude_code/command/registry.py +17 -5
  13. klaude_code/command/release_notes_cmd.py +5 -2
  14. klaude_code/command/status_cmd.py +8 -4
  15. klaude_code/command/terminal_setup_cmd.py +7 -4
  16. klaude_code/const/__init__.py +1 -1
  17. klaude_code/core/agent.py +55 -9
  18. klaude_code/core/executor.py +2 -2
  19. klaude_code/core/manager/agent_manager.py +6 -7
  20. klaude_code/core/manager/llm_clients.py +47 -22
  21. klaude_code/core/manager/llm_clients_builder.py +19 -7
  22. klaude_code/core/manager/sub_agent_manager.py +1 -1
  23. klaude_code/core/reminders.py +0 -3
  24. klaude_code/core/task.py +2 -2
  25. klaude_code/core/tool/file/_utils.py +30 -0
  26. klaude_code/core/tool/file/edit_tool.py +5 -30
  27. klaude_code/core/tool/file/multi_edit_tool.py +6 -31
  28. klaude_code/core/tool/file/read_tool.py +6 -18
  29. klaude_code/core/tool/file/write_tool.py +5 -30
  30. klaude_code/core/tool/memory/__init__.py +5 -0
  31. klaude_code/core/tool/memory/skill_loader.py +2 -1
  32. klaude_code/core/tool/memory/skill_tool.py +13 -0
  33. klaude_code/llm/__init__.py +2 -12
  34. klaude_code/llm/anthropic/client.py +2 -1
  35. klaude_code/llm/client.py +1 -1
  36. klaude_code/llm/codex/client.py +1 -1
  37. klaude_code/llm/openai_compatible/client.py +3 -2
  38. klaude_code/llm/openrouter/client.py +3 -3
  39. klaude_code/llm/registry.py +33 -7
  40. klaude_code/llm/responses/client.py +2 -1
  41. klaude_code/llm/responses/input.py +1 -1
  42. klaude_code/llm/usage.py +17 -8
  43. klaude_code/protocol/model.py +12 -7
  44. klaude_code/protocol/op.py +1 -0
  45. klaude_code/session/export.py +5 -5
  46. klaude_code/session/session.py +15 -5
  47. klaude_code/ui/core/input.py +1 -1
  48. klaude_code/ui/modes/repl/clipboard.py +5 -5
  49. klaude_code/ui/renderers/metadata.py +1 -1
  50. klaude_code/ui/terminal/control.py +2 -2
  51. klaude_code/version.py +3 -3
  52. {klaude_code-1.2.9.dist-info → klaude_code-1.2.10.dist-info}/METADATA +1 -1
  53. {klaude_code-1.2.9.dist-info → klaude_code-1.2.10.dist-info}/RECORD +55 -54
  54. {klaude_code-1.2.9.dist-info → klaude_code-1.2.10.dist-info}/WHEEL +0 -0
  55. {klaude_code-1.2.9.dist-info → klaude_code-1.2.10.dist-info}/entry_points.txt +0 -0
klaude_code/core/agent.py CHANGED
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import AsyncGenerator, Iterable
3
+ from collections.abc import AsyncGenerator, Callable, Iterable
4
4
  from dataclasses import dataclass
5
- from typing import Protocol
5
+ from typing import TYPE_CHECKING, Protocol
6
6
 
7
7
  from klaude_code.core.prompt import get_system_prompt as load_system_prompt
8
8
  from klaude_code.core.reminders import Reminder, load_agent_reminders
@@ -14,21 +14,38 @@ from klaude_code.protocol.model import UserInputPayload
14
14
  from klaude_code.session import Session
15
15
  from klaude_code.trace import DebugType, log_debug
16
16
 
17
+ if TYPE_CHECKING:
18
+ from klaude_code.core.manager.llm_clients import LLMClients
19
+
17
20
 
18
21
  @dataclass(frozen=True)
19
22
  class AgentProfile:
20
23
  """Encapsulates the active LLM client plus prompts/tools/reminders."""
21
24
 
22
- llm_client: LLMClientABC
25
+ llm_client_factory: Callable[[], LLMClientABC]
23
26
  system_prompt: str | None
24
27
  tools: list[llm_param.ToolSchema]
25
28
  reminders: list[Reminder]
26
29
 
30
+ _llm_client: LLMClientABC | None = None
31
+
32
+ @property
33
+ def llm_client(self) -> LLMClientABC:
34
+ if self._llm_client is None:
35
+ object.__setattr__(self, "_llm_client", self.llm_client_factory())
36
+ return self._llm_client # type: ignore[return-value]
37
+
27
38
 
28
39
  class ModelProfileProvider(Protocol):
29
40
  """Strategy interface for constructing agent profiles."""
30
41
 
31
42
  def build_profile(
43
+ self,
44
+ llm_clients: LLMClients,
45
+ sub_agent_type: tools.SubAgentType | None = None,
46
+ ) -> AgentProfile: ...
47
+
48
+ def build_profile_eager(
32
49
  self,
33
50
  llm_client: LLMClientABC,
34
51
  sub_agent_type: tools.SubAgentType | None = None,
@@ -39,13 +56,26 @@ class DefaultModelProfileProvider(ModelProfileProvider):
39
56
  """Default provider backed by global prompts/tool/reminder registries."""
40
57
 
41
58
  def build_profile(
59
+ self,
60
+ llm_clients: LLMClients,
61
+ sub_agent_type: tools.SubAgentType | None = None,
62
+ ) -> AgentProfile:
63
+ model_name = llm_clients.main_model_name
64
+ return AgentProfile(
65
+ llm_client_factory=lambda: llm_clients.main,
66
+ system_prompt=load_system_prompt(model_name, sub_agent_type),
67
+ tools=load_agent_tools(model_name, sub_agent_type),
68
+ reminders=load_agent_reminders(model_name, sub_agent_type),
69
+ )
70
+
71
+ def build_profile_eager(
42
72
  self,
43
73
  llm_client: LLMClientABC,
44
74
  sub_agent_type: tools.SubAgentType | None = None,
45
75
  ) -> AgentProfile:
46
76
  model_name = llm_client.model_name
47
77
  return AgentProfile(
48
- llm_client=llm_client,
78
+ llm_client_factory=lambda: llm_client,
49
79
  system_prompt=load_system_prompt(model_name, sub_agent_type),
50
80
  tools=load_agent_tools(model_name, sub_agent_type),
51
81
  reminders=load_agent_reminders(model_name, sub_agent_type),
@@ -56,13 +86,26 @@ class VanillaModelProfileProvider(ModelProfileProvider):
56
86
  """Provider that strips prompts, reminders, and tools for vanilla mode."""
57
87
 
58
88
  def build_profile(
89
+ self,
90
+ llm_clients: LLMClients,
91
+ sub_agent_type: tools.SubAgentType | None = None,
92
+ ) -> AgentProfile:
93
+ model_name = llm_clients.main_model_name
94
+ return AgentProfile(
95
+ llm_client_factory=lambda: llm_clients.main,
96
+ system_prompt=None,
97
+ tools=load_agent_tools(model_name, vanilla=True),
98
+ reminders=load_agent_reminders(model_name, vanilla=True),
99
+ )
100
+
101
+ def build_profile_eager(
59
102
  self,
60
103
  llm_client: LLMClientABC,
61
104
  sub_agent_type: tools.SubAgentType | None = None,
62
105
  ) -> AgentProfile:
63
106
  model_name = llm_client.model_name
64
107
  return AgentProfile(
65
- llm_client=llm_client,
108
+ llm_client_factory=lambda: llm_client,
66
109
  system_prompt=None,
67
110
  tools=load_agent_tools(model_name, vanilla=True),
68
111
  reminders=load_agent_reminders(model_name, vanilla=True),
@@ -74,12 +117,13 @@ class Agent:
74
117
  self,
75
118
  session: Session,
76
119
  profile: AgentProfile,
120
+ model_name: str | None = None,
77
121
  ):
78
122
  self.session: Session = session
79
123
  self.profile: AgentProfile = profile
80
124
  self._current_task: TaskExecutor | None = None
81
- if not self.session.model_name:
82
- self.session.model_name = profile.llm_client.model_name
125
+ if not self.session.model_name and model_name:
126
+ self.session.model_name = model_name
83
127
 
84
128
  def cancel(self) -> Iterable[events.Event]:
85
129
  """Handle agent cancellation and persist an interrupt marker and tool cancellations.
@@ -148,11 +192,13 @@ class Agent:
148
192
  self.session.append_history([item])
149
193
  yield events.DeveloperMessageEvent(session_id=self.session.id, item=item)
150
194
 
151
- def set_model_profile(self, profile: AgentProfile) -> None:
195
+ def set_model_profile(self, profile: AgentProfile, model_name: str | None = None) -> None:
152
196
  """Apply a fully constructed profile to the agent."""
153
197
 
154
198
  self.profile = profile
155
- if not self.session.model_name:
199
+ if model_name:
200
+ self.session.model_name = model_name
201
+ elif not self.session.model_name:
156
202
  self.session.model_name = profile.llm_client.model_name
157
203
 
158
204
  def get_llm_client(self) -> LLMClientABC:
@@ -117,7 +117,7 @@ class ExecutorContext:
117
117
  if operation.session_id is None:
118
118
  raise ValueError("session_id cannot be None")
119
119
 
120
- await self.agent_manager.ensure_agent(operation.session_id)
120
+ await self.agent_manager.ensure_agent(operation.session_id, is_new_session=operation.is_new_session)
121
121
 
122
122
  async def handle_user_input(self, operation: op.UserInputOperation) -> None:
123
123
  """Handle a user input operation by running it through an agent."""
@@ -482,4 +482,4 @@ class Executor:
482
482
 
483
483
  # Static type check: ExecutorContext must satisfy OperationHandler protocol.
484
484
  # If this line causes a type error, ExecutorContext is missing required methods.
485
- _: type[OperationHandler] = ExecutorContext # pyright: ignore[reportUnusedVariable]
485
+ _: type[OperationHandler] = ExecutorContext
@@ -38,16 +38,15 @@ class AgentManager:
38
38
 
39
39
  await self._event_queue.put(event)
40
40
 
41
- async def ensure_agent(self, session_id: str) -> Agent:
41
+ async def ensure_agent(self, session_id: str, *, is_new_session: bool = False) -> Agent:
42
42
  """Return an existing agent for the session or create a new one."""
43
-
44
43
  agent = self._active_agents.get(session_id)
45
44
  if agent is not None:
46
45
  return agent
47
46
 
48
- session = Session.load(session_id)
49
- profile = self._model_profile_provider.build_profile(self._llm_clients.main)
50
- agent = Agent(session=session, profile=profile)
47
+ session = Session.load(session_id, skip_if_missing=is_new_session)
48
+ profile = self._model_profile_provider.build_profile(self._llm_clients)
49
+ agent = Agent(session=session, profile=profile, model_name=self._llm_clients.main_model_name)
51
50
 
52
51
  async for evt in agent.replay_history():
53
52
  await self.emit_event(evt)
@@ -55,7 +54,7 @@ class AgentManager:
55
54
  await self.emit_event(
56
55
  events.WelcomeEvent(
57
56
  work_dir=str(session.work_dir),
58
- llm_config=self._llm_clients.main.get_llm_config(),
57
+ llm_config=self._llm_clients.get_llm_config(),
59
58
  )
60
59
  )
61
60
 
@@ -76,7 +75,7 @@ class AgentManager:
76
75
 
77
76
  llm_config = config.get_model_config(model_name)
78
77
  llm_client = create_llm_client(llm_config)
79
- agent.set_model_profile(self._model_profile_provider.build_profile(llm_client))
78
+ agent.set_model_profile(self._model_profile_provider.build_profile_eager(llm_client), model_name=model_name)
80
79
 
81
80
  developer_item = model.DeveloperMessageItem(
82
81
  content=f"switched to model: {model_name}",
@@ -2,41 +2,66 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
6
- from dataclasses import field as dataclass_field
5
+ from collections.abc import Callable
7
6
 
8
7
  from klaude_code.llm.client import LLMClientABC
8
+ from klaude_code.protocol import llm_param
9
9
  from klaude_code.protocol.tools import SubAgentType
10
10
 
11
11
 
12
- def _default_sub_clients() -> dict[SubAgentType, LLMClientABC]:
13
- """Return an empty mapping for sub-agent clients.
12
+ class LLMClients:
13
+ """Container for LLM clients used by main agent and sub-agents."""
14
14
 
15
- Defined separately so static type checkers can infer the dictionary
16
- key and value types instead of treating them as ``Unknown``.
17
- """
15
+ def __init__(
16
+ self,
17
+ main_factory: Callable[[], LLMClientABC],
18
+ main_model_name: str,
19
+ main_llm_config: llm_param.LLMConfigParameter,
20
+ ) -> None:
21
+ self._main_factory: Callable[[], LLMClientABC] | None = main_factory
22
+ self._main_client: LLMClientABC | None = None
23
+ self._main_model_name: str = main_model_name
24
+ self._main_llm_config: llm_param.LLMConfigParameter = main_llm_config
25
+ self._sub_clients: dict[SubAgentType, LLMClientABC] = {}
26
+ self._sub_factories: dict[SubAgentType, Callable[[], LLMClientABC]] = {}
18
27
 
19
- return {}
28
+ @property
29
+ def main_model_name(self) -> str:
30
+ return self._main_model_name
20
31
 
32
+ def get_llm_config(self) -> llm_param.LLMConfigParameter:
33
+ return self._main_llm_config
21
34
 
22
- @dataclass
23
- class LLMClients:
24
- """Container for LLM clients used by main agent and sub-agents."""
35
+ @property
36
+ def main(self) -> LLMClientABC:
37
+ if self._main_client is None:
38
+ if self._main_factory is None:
39
+ raise RuntimeError("Main client factory not set")
40
+ self._main_client = self._main_factory()
41
+ self._main_factory = None
42
+ return self._main_client
25
43
 
26
- main: LLMClientABC
27
- sub_clients: dict[SubAgentType, LLMClientABC] = dataclass_field(default_factory=_default_sub_clients)
44
+ def register_sub_client_factory(
45
+ self,
46
+ sub_agent_type: SubAgentType,
47
+ factory: Callable[[], LLMClientABC],
48
+ ) -> None:
49
+ self._sub_factories[sub_agent_type] = factory
28
50
 
29
51
  def get_client(self, sub_agent_type: SubAgentType | None = None) -> LLMClientABC:
30
- """Return client for a sub-agent type or the main client.
52
+ """Return client for a sub-agent type or the main client."""
31
53
 
32
- Args:
33
- sub_agent_type: Optional sub-agent type whose client should be returned.
54
+ if sub_agent_type is None:
55
+ return self.main
34
56
 
35
- Returns:
36
- The LLM client corresponding to the sub-agent type, or the main client
37
- when no specialized client is available.
38
- """
57
+ existing = self._sub_clients.get(sub_agent_type)
58
+ if existing is not None:
59
+ return existing
39
60
 
40
- if sub_agent_type is None:
61
+ factory = self._sub_factories.get(sub_agent_type)
62
+ if factory is None:
41
63
  return self.main
42
- return self.sub_clients.get(sub_agent_type) or self.main
64
+
65
+ client = factory()
66
+ self._sub_clients[sub_agent_type] = client
67
+ return client
@@ -32,18 +32,30 @@ def build_llm_clients(
32
32
  debug_type=DebugType.LLM_CONFIG,
33
33
  )
34
34
 
35
- main_client = create_llm_client(llm_config)
36
- sub_clients: dict[SubAgentType, LLMClientABC] = {}
35
+ main_model_name = str(llm_config.model)
36
+
37
+ def _main_factory() -> LLMClientABC:
38
+ return create_llm_client(llm_config)
39
+
40
+ clients = LLMClients(
41
+ main_factory=_main_factory,
42
+ main_model_name=main_model_name,
43
+ main_llm_config=llm_config,
44
+ )
37
45
 
38
- # Initialize sub-agent clients
39
46
  for sub_agent_type in enabled_sub_agents or []:
40
47
  model_name = config.subagent_models.get(sub_agent_type)
41
48
  if not model_name:
42
49
  continue
50
+
43
51
  profile = get_sub_agent_profile(sub_agent_type)
44
- if not profile.enabled_for_model(main_client.model_name):
52
+ if not profile.enabled_for_model(main_model_name):
45
53
  continue
46
- sub_llm_config = config.get_model_config(model_name)
47
- sub_clients[sub_agent_type] = create_llm_client(sub_llm_config)
48
54
 
49
- return LLMClients(main=main_client, sub_clients=sub_clients)
55
+ def _factory(model_name_for_factory: str = model_name) -> LLMClientABC:
56
+ sub_llm_config = config.get_model_config(model_name_for_factory)
57
+ return create_llm_client(sub_llm_config)
58
+
59
+ clients.register_sub_client_factory(sub_agent_type, _factory)
60
+
61
+ return clients
@@ -43,7 +43,7 @@ class SubAgentManager:
43
43
  child_session = Session(work_dir=parent_session.work_dir)
44
44
  child_session.sub_agent_state = state
45
45
 
46
- child_profile = self._model_profile_provider.build_profile(
46
+ child_profile = self._model_profile_provider.build_profile_eager(
47
47
  self._llm_clients.get_client(state.sub_agent_type),
48
48
  state.sub_agent_type,
49
49
  )
@@ -344,9 +344,6 @@ async def last_path_memory_reminder(
344
344
  paths.append(path)
345
345
  except json.JSONDecodeError:
346
346
  continue
347
- elif tool_call.name == tools.BASH:
348
- # TODO: haiku check file path
349
- pass
350
347
  paths = list(set(paths))
351
348
  memories: list[Memory] = []
352
349
  if len(paths) == 0:
klaude_code/core/task.py CHANGED
@@ -45,8 +45,8 @@ class MetadataAccumulator:
45
45
  acc_usage.output_tokens += usage.output_tokens
46
46
  acc_usage.currency = usage.currency
47
47
 
48
- if usage.context_window_size is not None:
49
- acc_usage.context_window_size = usage.context_window_size
48
+ if usage.context_token is not None:
49
+ acc_usage.context_token = usage.context_token
50
50
  if usage.context_limit is not None:
51
51
  acc_usage.context_limit = usage.context_limit
52
52
 
@@ -0,0 +1,30 @@
1
+ """Shared utility functions for file tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def is_directory(path: str) -> bool:
10
+ """Check if path is a directory."""
11
+ return os.path.isdir(path)
12
+
13
+
14
+ def file_exists(path: str) -> bool:
15
+ """Check if path exists."""
16
+ return os.path.exists(path)
17
+
18
+
19
+ def read_text(path: str) -> str:
20
+ """Read text from file with UTF-8 encoding."""
21
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
22
+ return f.read()
23
+
24
+
25
+ def write_text(path: str, content: str) -> None:
26
+ """Write text to file, creating parent directories if needed."""
27
+ parent = Path(path).parent
28
+ parent.mkdir(parents=True, exist_ok=True)
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ f.write(content)
@@ -7,38 +7,13 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
12
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
13
  from klaude_code.core.tool.tool_registry import register
13
14
  from klaude_code.protocol import llm_param, model, tools
14
15
 
15
16
 
16
- def _is_directory(path: str) -> bool:
17
- try:
18
- return Path(path).is_dir()
19
- except Exception:
20
- return False
21
-
22
-
23
- def _file_exists(path: str) -> bool:
24
- try:
25
- return Path(path).exists()
26
- except Exception:
27
- return False
28
-
29
-
30
- def _read_text(path: str) -> str:
31
- with open(path, "r", encoding="utf-8", errors="replace") as f:
32
- return f.read()
33
-
34
-
35
- def _write_text(path: str, content: str) -> None:
36
- parent = Path(path).parent
37
- parent.mkdir(parents=True, exist_ok=True)
38
- with open(path, "w", encoding="utf-8") as f:
39
- f.write(content)
40
-
41
-
42
17
  @register(tools.EDIT)
43
18
  class EditTool(ToolABC):
44
19
  class EditArguments(BaseModel):
@@ -119,7 +94,7 @@ class EditTool(ToolABC):
119
94
  file_path = os.path.abspath(args.file_path)
120
95
 
121
96
  # Common file errors
122
- if _is_directory(file_path):
97
+ if is_directory(file_path):
123
98
  return model.ToolResultItem(
124
99
  status="error",
125
100
  output="<tool_use_error>Illegal operation on a directory. edit</tool_use_error>",
@@ -136,7 +111,7 @@ class EditTool(ToolABC):
136
111
 
137
112
  # FileTracker checks (only for editing existing files)
138
113
  file_tracker = get_current_file_tracker()
139
- if not _file_exists(file_path):
114
+ if not file_exists(file_path):
140
115
  # We require reading before editing
141
116
  return model.ToolResultItem(
142
117
  status="error",
@@ -163,7 +138,7 @@ class EditTool(ToolABC):
163
138
 
164
139
  # Edit existing file: validate and apply
165
140
  try:
166
- before = await asyncio.to_thread(_read_text, file_path)
141
+ before = await asyncio.to_thread(read_text, file_path)
167
142
  except FileNotFoundError:
168
143
  return model.ToolResultItem(
169
144
  status="error",
@@ -197,7 +172,7 @@ class EditTool(ToolABC):
197
172
 
198
173
  # Write back
199
174
  try:
200
- await asyncio.to_thread(_write_text, file_path, after)
175
+ await asyncio.to_thread(write_text, file_path, after)
201
176
  except Exception as e: # pragma: no cover
202
177
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
203
178
 
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.file.edit_tool import EditTool
11
12
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
12
13
  from klaude_code.core.tool.tool_context import get_current_file_tracker
@@ -14,32 +15,6 @@ from klaude_code.core.tool.tool_registry import register
14
15
  from klaude_code.protocol import llm_param, model, tools
15
16
 
16
17
 
17
- def _is_directory(path: str) -> bool:
18
- try:
19
- return Path(path).is_dir()
20
- except Exception:
21
- return False
22
-
23
-
24
- def _file_exists(path: str) -> bool:
25
- try:
26
- return Path(path).exists()
27
- except Exception:
28
- return False
29
-
30
-
31
- def _read_text(path: str) -> str:
32
- with open(path, "r", encoding="utf-8", errors="replace") as f:
33
- return f.read()
34
-
35
-
36
- def _write_text(path: str, content: str) -> None:
37
- parent = Path(path).parent
38
- parent.mkdir(parents=True, exist_ok=True)
39
- with open(path, "w", encoding="utf-8") as f:
40
- f.write(content)
41
-
42
-
43
18
  @register(tools.MULTI_EDIT)
44
19
  class MultiEditTool(ToolABC):
45
20
  class MultiEditEditItem(BaseModel):
@@ -105,7 +80,7 @@ class MultiEditTool(ToolABC):
105
80
  file_path = os.path.abspath(args.file_path)
106
81
 
107
82
  # Directory error first
108
- if _is_directory(file_path):
83
+ if is_directory(file_path):
109
84
  return model.ToolResultItem(
110
85
  status="error",
111
86
  output="<tool_use_error>Illegal operation on a directory. multi_edit</tool_use_error>",
@@ -114,7 +89,7 @@ class MultiEditTool(ToolABC):
114
89
  file_tracker = get_current_file_tracker()
115
90
 
116
91
  # FileTracker check:
117
- if _file_exists(file_path):
92
+ if file_exists(file_path):
118
93
  if file_tracker is not None:
119
94
  tracked = file_tracker.get(file_path)
120
95
  if tracked is None:
@@ -142,8 +117,8 @@ class MultiEditTool(ToolABC):
142
117
  )
143
118
 
144
119
  # Load initial content (empty for new file case)
145
- if _file_exists(file_path):
146
- before = await asyncio.to_thread(_read_text, file_path)
120
+ if file_exists(file_path):
121
+ before = await asyncio.to_thread(read_text, file_path)
147
122
  else:
148
123
  before = ""
149
124
 
@@ -168,7 +143,7 @@ class MultiEditTool(ToolABC):
168
143
 
169
144
  # All edits valid; write to disk
170
145
  try:
171
- await asyncio.to_thread(_write_text, file_path, staged)
146
+ await asyncio.to_thread(write_text, file_path, staged)
172
147
  except Exception as e: # pragma: no cover
173
148
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
174
149
 
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from pydantic import BaseModel, Field
10
10
 
11
11
  from klaude_code import const
12
+ from klaude_code.core.tool.file._utils import file_exists, is_directory
12
13
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
13
14
  from klaude_code.core.tool.tool_context import get_current_file_tracker
14
15
  from klaude_code.core.tool.tool_registry import register
@@ -34,20 +35,6 @@ def _format_numbered_line(line_no: int, content: str) -> str:
34
35
  return f"{line_no:>6}→{content}"
35
36
 
36
37
 
37
- def _is_directory(path: str) -> bool:
38
- try:
39
- return Path(path).is_dir()
40
- except Exception:
41
- return False
42
-
43
-
44
- def _file_exists(path: str) -> bool:
45
- try:
46
- return Path(path).exists()
47
- except Exception:
48
- return False
49
-
50
-
51
38
  @dataclass
52
39
  class ReadOptions:
53
40
  file_path: str
@@ -101,7 +88,7 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
101
88
 
102
89
  def _track_file_access(file_path: str) -> None:
103
90
  file_tracker = get_current_file_tracker()
104
- if file_tracker is None or not _file_exists(file_path) or _is_directory(file_path):
91
+ if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
105
92
  return
106
93
  try:
107
94
  file_tracker[file_path] = Path(file_path).stat().st_mtime
@@ -188,12 +175,12 @@ class ReadTool(ToolABC):
188
175
  char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
189
176
 
190
177
  # Common file errors
191
- if _is_directory(file_path):
178
+ if is_directory(file_path):
192
179
  return model.ToolResultItem(
193
180
  status="error",
194
181
  output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
195
182
  )
196
- if not _file_exists(file_path):
183
+ if not file_exists(file_path):
197
184
  return model.ToolResultItem(
198
185
  status="error",
199
186
  output="<tool_use_error>File does not exist.</tool_use_error>",
@@ -222,7 +209,8 @@ class ReadTool(ToolABC):
222
209
  # If file is too large and no pagination provided (only check if limits are enabled)
223
210
  try:
224
211
  size_bytes = Path(file_path).stat().st_size
225
- except Exception:
212
+ except OSError:
213
+ # Best-effort size detection; on stat errors fall back to treating size as unknown.
226
214
  size_bytes = 0
227
215
 
228
216
  is_image_file = _is_supported_image_file(file_path)
@@ -7,38 +7,13 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
12
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
13
  from klaude_code.core.tool.tool_registry import register
13
14
  from klaude_code.protocol import llm_param, model, tools
14
15
 
15
16
 
16
- def _is_directory(path: str) -> bool:
17
- try:
18
- return Path(path).is_dir()
19
- except Exception:
20
- return False
21
-
22
-
23
- def _file_exists(path: str) -> bool:
24
- try:
25
- return Path(path).exists()
26
- except Exception:
27
- return False
28
-
29
-
30
- def _write_text(path: str, content: str) -> None:
31
- parent = Path(path).parent
32
- parent.mkdir(parents=True, exist_ok=True)
33
- with open(path, "w", encoding="utf-8") as f:
34
- f.write(content)
35
-
36
-
37
- def _read_text(path: str) -> str:
38
- with open(path, "r", encoding="utf-8", errors="replace") as f:
39
- return f.read()
40
-
41
-
42
17
  class WriteArguments(BaseModel):
43
18
  file_path: str
44
19
  content: str
@@ -78,14 +53,14 @@ class WriteTool(ToolABC):
78
53
 
79
54
  file_path = os.path.abspath(args.file_path)
80
55
 
81
- if _is_directory(file_path):
56
+ if is_directory(file_path):
82
57
  return model.ToolResultItem(
83
58
  status="error",
84
59
  output="<tool_use_error>Illegal operation on a directory. write</tool_use_error>",
85
60
  )
86
61
 
87
62
  file_tracker = get_current_file_tracker()
88
- exists = _file_exists(file_path)
63
+ exists = file_exists(file_path)
89
64
 
90
65
  if exists:
91
66
  tracked_mtime: float | None = None
@@ -113,12 +88,12 @@ class WriteTool(ToolABC):
113
88
  before = ""
114
89
  if exists:
115
90
  try:
116
- before = await asyncio.to_thread(_read_text, file_path)
91
+ before = await asyncio.to_thread(read_text, file_path)
117
92
  except Exception:
118
93
  before = ""
119
94
 
120
95
  try:
121
- await asyncio.to_thread(_write_text, file_path, args.content)
96
+ await asyncio.to_thread(write_text, file_path, args.content)
122
97
  except Exception as e: # pragma: no cover
123
98
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
124
99
 
@@ -0,0 +1,5 @@
1
+ from .skill_loader import SkillLoader
2
+ from .skill_tool import SkillTool
3
+
4
+ skill_loader = SkillLoader()
5
+ SkillTool.set_skill_loader(skill_loader)