crabcode 0.1.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 (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. crabcode_search/tool.py +232 -0
@@ -0,0 +1,112 @@
1
+ """SkillTool — lets the model invoke user-defined skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolResult
8
+
9
+ if TYPE_CHECKING:
10
+ from crabcode_core.skills.loader import SkillDefinition
11
+ from crabcode_core.types.tool import ToolContext
12
+
13
+
14
+ class SkillTool(Tool):
15
+ """Execute a user-defined skill loaded from SKILL.md files.
16
+
17
+ The model calls this tool when the user invokes a skill by name (e.g.
18
+ ``/commit``) or when the task naturally matches a skill's description.
19
+ The tool expands the skill's Markdown content and returns it so the model
20
+ can follow the instructions.
21
+ """
22
+
23
+ name = "Skill"
24
+ is_read_only = True
25
+ is_concurrency_safe = True
26
+
27
+ input_schema: dict[str, Any] = {
28
+ "type": "object",
29
+ "properties": {
30
+ "skill_name": {
31
+ "type": "string",
32
+ "description": "Name of the skill to execute.",
33
+ },
34
+ "user_input": {
35
+ "type": "string",
36
+ "description": "The user's additional input or context for the skill.",
37
+ },
38
+ },
39
+ "required": ["skill_name"],
40
+ }
41
+
42
+ def __init__(self, skills: list[SkillDefinition]) -> None:
43
+ self._skills = skills
44
+ self._skill_map = {s.name: s for s in skills}
45
+
46
+ async def get_prompt(self, **kwargs: Any) -> str:
47
+ lines = [
48
+ "Execute a user-defined skill.\n",
49
+ "When a user invokes /<skill-name> or their request clearly matches a skill's",
50
+ "description, call this tool with the matching skill_name BEFORE generating",
51
+ "any other response. The tool will return the skill's instructions, which you",
52
+ "should then follow to complete the task.\n",
53
+ ]
54
+
55
+ if self._skills:
56
+ lines.append("## Available skills\n")
57
+ for skill in self._skills:
58
+ lines.append(f"### {skill.name}")
59
+ if skill.description:
60
+ lines.append(f"Description: {skill.description}")
61
+ if skill.when_to_use:
62
+ lines.append(f"When to use: {skill.when_to_use}")
63
+ lines.append("")
64
+
65
+ lines.append(
66
+ "IMPORTANT: Only invoke skills listed above. "
67
+ "Do not guess or invent skill names."
68
+ )
69
+ return "\n".join(lines)
70
+
71
+ async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
72
+ skill_name = tool_input.get("skill_name", "")
73
+ if skill_name not in self._skill_map:
74
+ available = ", ".join(self._skill_map) or "(none)"
75
+ return f"Unknown skill '{skill_name}'. Available skills: {available}"
76
+ return None
77
+
78
+ async def check_permissions(
79
+ self,
80
+ tool_input: dict[str, Any],
81
+ context: ToolContext,
82
+ ) -> PermissionResult:
83
+ return PermissionResult(behavior=PermissionBehavior.ALLOW)
84
+
85
+ async def call(
86
+ self,
87
+ tool_input: dict[str, Any],
88
+ context: ToolContext,
89
+ ) -> ToolResult:
90
+ skill_name = tool_input.get("skill_name", "")
91
+ user_input = tool_input.get("user_input", "")
92
+
93
+ skill = self._skill_map.get(skill_name)
94
+ if not skill:
95
+ available = ", ".join(self._skill_map) or "(none)"
96
+ return ToolResult(
97
+ is_error=True,
98
+ result_for_model=f"Unknown skill '{skill_name}'. Available: {available}",
99
+ )
100
+
101
+ content = skill.content
102
+ if user_input:
103
+ # Support explicit $USER_INPUT placeholder; otherwise append
104
+ if "$USER_INPUT" in content:
105
+ content = content.replace("$USER_INPUT", user_input)
106
+ else:
107
+ content = f"{content}\n\nUser input: {user_input}"
108
+
109
+ return ToolResult(
110
+ result_for_model=content,
111
+ result_for_display=f"[Skill: {skill_name}]",
112
+ )
@@ -0,0 +1,73 @@
1
+ """Core type definitions for CrabCode."""
2
+
3
+ from crabcode_core.types.message import (
4
+ AssistantMessage,
5
+ ContentBlock,
6
+ Message,
7
+ MessageRole,
8
+ SystemMessage,
9
+ TextBlock,
10
+ ThinkingBlock,
11
+ ToolResultBlock,
12
+ ToolUseBlock,
13
+ UserMessage,
14
+ )
15
+ from crabcode_core.types.tool import (
16
+ PermissionBehavior,
17
+ PermissionResult,
18
+ Tool,
19
+ ToolContext,
20
+ ToolResult,
21
+ )
22
+ from crabcode_core.types.event import (
23
+ CompactEvent,
24
+ CoreEvent,
25
+ ErrorEvent,
26
+ PermissionRequestEvent,
27
+ PermissionResponseEvent,
28
+ StreamTextEvent,
29
+ ThinkingEvent,
30
+ ToolResultEvent,
31
+ ToolUseEvent,
32
+ TurnCompleteEvent,
33
+ )
34
+ from crabcode_core.types.config import (
35
+ ApiConfig,
36
+ CrabCodeSettings,
37
+ McpServerConfig,
38
+ PermissionRule,
39
+ PermissionsSettings,
40
+ )
41
+
42
+ __all__ = [
43
+ "AssistantMessage",
44
+ "ContentBlock",
45
+ "Message",
46
+ "MessageRole",
47
+ "SystemMessage",
48
+ "TextBlock",
49
+ "ThinkingBlock",
50
+ "ToolResultBlock",
51
+ "ToolUseBlock",
52
+ "UserMessage",
53
+ "PermissionBehavior",
54
+ "PermissionResult",
55
+ "Tool",
56
+ "ToolContext",
57
+ "ToolResult",
58
+ "CompactEvent",
59
+ "CoreEvent",
60
+ "ErrorEvent",
61
+ "PermissionRequestEvent",
62
+ "PermissionResponseEvent",
63
+ "StreamTextEvent",
64
+ "ThinkingEvent",
65
+ "ToolResultEvent",
66
+ "ToolUseEvent",
67
+ "TurnCompleteEvent",
68
+ "ApiConfig",
69
+ "CrabCodeSettings",
70
+ "McpServerConfig",
71
+ "PermissionRule",
72
+ "PermissionsSettings",
73
+ ]
@@ -0,0 +1,103 @@
1
+ """Configuration types for CrabCode settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class PermissionRule(BaseModel):
11
+ """A single permission rule for tool access control."""
12
+ tool: str
13
+ path: str | None = None
14
+ command: str | None = None
15
+
16
+
17
+ class PermissionsSettings(BaseModel):
18
+ allow: list[PermissionRule] = Field(default_factory=list)
19
+ deny: list[PermissionRule] = Field(default_factory=list)
20
+ ask: list[PermissionRule] = Field(default_factory=list)
21
+ default_mode: str | None = None
22
+ additional_directories: list[str] = Field(default_factory=list)
23
+ run_everything: bool = False
24
+
25
+
26
+ class McpServerConfig(BaseModel):
27
+ command: list[str] | None = None
28
+ url: str | None = None
29
+ env: dict[str, str] = Field(default_factory=dict)
30
+ disabled: bool = False
31
+
32
+
33
+ class ApiConfig(BaseModel):
34
+ """API backend configuration."""
35
+ provider: str | None = None # anthropic | openai | codex | bedrock | vertex | router
36
+ model: str | None = None
37
+ base_url: str | None = None
38
+ api_key_env: str | None = None
39
+ format: str | None = None # anthropic | openai | codex (for routers)
40
+ max_tokens: int = 16384
41
+ thinking_enabled: bool = True
42
+ thinking_budget: int = 10000
43
+ timeout: int = 300 # seconds, for API calls
44
+
45
+
46
+ class AgentSettings(BaseModel):
47
+ """Settings for the built-in Agent (sub-agent) tool."""
48
+ max_turns: int = 10
49
+ timeout: int = 300
50
+ max_output_chars: int = 12000
51
+
52
+
53
+ class DisplaySettings(BaseModel):
54
+ """Settings for tool result display in the terminal."""
55
+ default_max_lines: int = 50
56
+ tool_max_lines: dict[str, int] = {}
57
+ max_chars: int = 50_000
58
+
59
+ # Built-in defaults merged under tool_max_lines overrides
60
+ _TOOL_DEFAULTS: dict[str, int] = {
61
+ "Agent": 120,
62
+ "Bash": 60,
63
+ "Grep": 50,
64
+ "Glob": 30,
65
+ "Read": 80,
66
+ "Lint": 60,
67
+ "CodebaseSearch": 50,
68
+ }
69
+
70
+ def get_max_lines(self, tool_name: str) -> int:
71
+ """Return max display lines for a tool, considering overrides."""
72
+ if tool_name in self.tool_max_lines:
73
+ return self.tool_max_lines[tool_name]
74
+ return self._TOOL_DEFAULTS.get(tool_name, self.default_max_lines)
75
+
76
+
77
+ class CrabCodeSettings(BaseModel):
78
+ """Full settings.json schema."""
79
+ permissions: PermissionsSettings = Field(default_factory=PermissionsSettings)
80
+ env: dict[str, str] = Field(default_factory=dict)
81
+ mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)
82
+ api: ApiConfig = Field(default_factory=ApiConfig)
83
+ models: dict[str, ApiConfig] = Field(default_factory=dict)
84
+ default_model: str | None = None
85
+ hooks: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
86
+ auto_compact_enabled: bool = True
87
+ max_context_length: int | None = None
88
+ language: str | None = None
89
+ output_style: str | None = None
90
+ prompt_profile: dict[str, Any] | None = None
91
+ extra_tools: list[str] = Field(default_factory=list)
92
+ tool_settings: dict[str, dict[str, Any]] = Field(default_factory=dict)
93
+ agent: AgentSettings = Field(default_factory=AgentSettings)
94
+ display: DisplaySettings = Field(default_factory=DisplaySettings)
95
+
96
+ model_config = {"extra": "allow"}
97
+
98
+ def get_api_config(self, model_name: str | None = None) -> ApiConfig:
99
+ """Return the ApiConfig for a named model, falling back to the default api config."""
100
+ name = model_name or self.default_model
101
+ if name and name in self.models:
102
+ return self.models[name]
103
+ return self.api
@@ -0,0 +1,105 @@
1
+ """Event types for Core <-> Frontend communication.
2
+
3
+ The Core emits these events as an async stream. Frontends (CLI, Web UI, SDK)
4
+ consume them to render output, handle permission prompts, etc.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Union
11
+
12
+
13
+ @dataclass
14
+ class StreamTextEvent:
15
+ """A chunk of streamed text from the model."""
16
+ text: str
17
+
18
+
19
+ @dataclass
20
+ class ThinkingEvent:
21
+ """A chunk of thinking/reasoning from the model."""
22
+ text: str
23
+
24
+
25
+ @dataclass
26
+ class ToolUseEvent:
27
+ """The model wants to use a tool."""
28
+ tool_name: str
29
+ tool_input: dict[str, Any]
30
+ tool_use_id: str
31
+
32
+
33
+ @dataclass
34
+ class ToolResultEvent:
35
+ """Result from a tool execution."""
36
+ tool_use_id: str
37
+ tool_name: str
38
+ result: str
39
+ is_error: bool = False
40
+ result_for_display: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class PermissionRequestEvent:
45
+ """Core is requesting permission from the frontend to use a tool."""
46
+ tool_name: str
47
+ tool_input: dict[str, Any]
48
+ tool_use_id: str
49
+
50
+
51
+ @dataclass
52
+ class PermissionResponseEvent:
53
+ """Frontend's response to a permission request."""
54
+ tool_use_id: str
55
+ allowed: bool
56
+ always_allow: bool = False
57
+
58
+
59
+ @dataclass
60
+ class CompactEvent:
61
+ """Conversation was compacted."""
62
+ summary: str
63
+ messages_before: int = 0
64
+ messages_after: int = 0
65
+
66
+
67
+ @dataclass
68
+ class ErrorEvent:
69
+ """An error occurred."""
70
+ message: str
71
+ recoverable: bool = True
72
+ error_type: str = ""
73
+
74
+
75
+ @dataclass
76
+ class TurnCompleteEvent:
77
+ """An agentic turn completed."""
78
+ reason: str = "end_turn"
79
+ turn_count: int = 0
80
+ usage: dict[str, Any] = field(default_factory=dict)
81
+
82
+
83
+ @dataclass
84
+ class StreamModeEvent:
85
+ """Signals a phase transition in the streaming lifecycle.
86
+
87
+ Modes:
88
+ - "requesting": API request sent, waiting for first chunk
89
+ - "thinking": model entered extended thinking
90
+ - "responding": model is streaming text output
91
+ """
92
+ mode: str
93
+
94
+
95
+ CoreEvent = Union[
96
+ StreamTextEvent,
97
+ ThinkingEvent,
98
+ ToolUseEvent,
99
+ ToolResultEvent,
100
+ PermissionRequestEvent,
101
+ CompactEvent,
102
+ ErrorEvent,
103
+ TurnCompleteEvent,
104
+ StreamModeEvent,
105
+ ]
@@ -0,0 +1,177 @@
1
+ """Message types for the CrabCode conversation protocol.
2
+
3
+ Internal format follows Anthropic's message structure. API adapters
4
+ translate to/from provider-specific formats.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from typing import Any, Literal, Union
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class MessageRole(str, Enum):
18
+ USER = "user"
19
+ ASSISTANT = "assistant"
20
+ SYSTEM = "system"
21
+
22
+
23
+ # --- Content blocks ---
24
+
25
+
26
+ class TextBlock(BaseModel):
27
+ type: Literal["text"] = "text"
28
+ text: str
29
+
30
+
31
+ class ToolUseBlock(BaseModel):
32
+ type: Literal["tool_use"] = "tool_use"
33
+ id: str
34
+ name: str
35
+ input: dict[str, Any]
36
+
37
+
38
+ class ToolResultBlock(BaseModel):
39
+ type: Literal["tool_result"] = "tool_result"
40
+ tool_use_id: str
41
+ content: str
42
+ is_error: bool = False
43
+
44
+
45
+ class ThinkingBlock(BaseModel):
46
+ type: Literal["thinking"] = "thinking"
47
+ thinking: str
48
+
49
+
50
+ class SignatureBlock(BaseModel):
51
+ type: Literal["signature"] = "signature"
52
+ signature: str
53
+
54
+
55
+ ContentBlock = Union[TextBlock, ToolUseBlock, ToolResultBlock, ThinkingBlock, SignatureBlock]
56
+
57
+
58
+ # --- Messages ---
59
+
60
+
61
+ class Message(BaseModel):
62
+ """Base message in a conversation."""
63
+
64
+ uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
65
+ parent_uuid: str | None = None
66
+ role: MessageRole
67
+ content: list[ContentBlock] | str
68
+ timestamp: str = Field(
69
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
70
+ )
71
+ is_compact_summary: bool = False
72
+
73
+ @property
74
+ def text_content(self) -> str:
75
+ """Extract plain text from content blocks."""
76
+ if isinstance(self.content, str):
77
+ return self.content
78
+ parts: list[str] = []
79
+ for block in self.content:
80
+ if isinstance(block, TextBlock):
81
+ parts.append(block.text)
82
+ return "".join(parts)
83
+
84
+ @property
85
+ def tool_use_blocks(self) -> list[ToolUseBlock]:
86
+ if isinstance(self.content, str):
87
+ return []
88
+ return [b for b in self.content if isinstance(b, ToolUseBlock)]
89
+
90
+ @property
91
+ def has_tool_use(self) -> bool:
92
+ return len(self.tool_use_blocks) > 0
93
+
94
+
95
+ class UserMessage(Message):
96
+ role: Literal[MessageRole.USER] = MessageRole.USER
97
+ tool_use_result: str | None = None
98
+ source_tool_assistant_uuid: str | None = None
99
+
100
+
101
+ class AssistantMessage(Message):
102
+ role: Literal[MessageRole.ASSISTANT] = MessageRole.ASSISTANT
103
+ api_error: str | None = None
104
+ usage: dict[str, Any] | None = None
105
+ request_id: str | None = None
106
+
107
+
108
+ class SystemMessage(Message):
109
+ role: Literal[MessageRole.SYSTEM] = MessageRole.SYSTEM
110
+
111
+
112
+ def create_user_message(
113
+ content: list[ContentBlock] | str,
114
+ **kwargs: Any,
115
+ ) -> UserMessage:
116
+ return UserMessage(content=content, **kwargs)
117
+
118
+
119
+ def create_assistant_message(
120
+ content: list[ContentBlock] | str,
121
+ **kwargs: Any,
122
+ ) -> AssistantMessage:
123
+ return AssistantMessage(content=content, **kwargs)
124
+
125
+
126
+ def create_tool_result_message(
127
+ tool_use_id: str,
128
+ result: str,
129
+ is_error: bool = False,
130
+ source_tool_assistant_uuid: str | None = None,
131
+ ) -> UserMessage:
132
+ """Create a user message containing a tool result."""
133
+ return UserMessage(
134
+ content=[
135
+ ToolResultBlock(
136
+ tool_use_id=tool_use_id,
137
+ content=result,
138
+ is_error=is_error,
139
+ )
140
+ ],
141
+ parent_uuid=source_tool_assistant_uuid,
142
+ tool_use_result=result,
143
+ source_tool_assistant_uuid=source_tool_assistant_uuid,
144
+ )
145
+
146
+
147
+ def deserialize_content(raw: Any) -> list[ContentBlock] | str:
148
+ """Deserialize raw JSON content back into typed ContentBlock list."""
149
+ if isinstance(raw, str):
150
+ return raw
151
+ if not isinstance(raw, list):
152
+ return str(raw) if raw else ""
153
+
154
+ _BLOCK_MAP: dict[str, type[BaseModel]] = {
155
+ "text": TextBlock,
156
+ "tool_use": ToolUseBlock,
157
+ "tool_result": ToolResultBlock,
158
+ "thinking": ThinkingBlock,
159
+ "signature": SignatureBlock,
160
+ }
161
+
162
+ blocks: list[ContentBlock] = []
163
+ for item in raw:
164
+ if not isinstance(item, dict):
165
+ continue
166
+ block_type = item.get("type", "")
167
+ cls = _BLOCK_MAP.get(block_type)
168
+ if cls:
169
+ try:
170
+ blocks.append(cls.model_validate(item))
171
+ except Exception:
172
+ if "text" in item:
173
+ blocks.append(TextBlock(text=item["text"]))
174
+ elif "text" in item:
175
+ blocks.append(TextBlock(text=item["text"]))
176
+
177
+ return blocks if blocks else ""
@@ -0,0 +1,138 @@
1
+ """Tool system types for CrabCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine
10
+
11
+ if TYPE_CHECKING:
12
+ from crabcode_core.types.message import AssistantMessage, Message
13
+
14
+
15
+ class PermissionBehavior(str, Enum):
16
+ ALLOW = "allow"
17
+ DENY = "deny"
18
+ ASK = "ask"
19
+
20
+
21
+ ToolEventCallback = Callable[[str, str, dict[str, Any]], None]
22
+ """Signature: (tool_name, event_type, data)"""
23
+
24
+
25
+ @dataclass
26
+ class PermissionResult:
27
+ behavior: PermissionBehavior = PermissionBehavior.ALLOW
28
+ reason: str | None = None
29
+ updated_input: dict[str, Any] | None = None
30
+
31
+
32
+ @dataclass
33
+ class ToolResult:
34
+ """Result from executing a tool."""
35
+
36
+ data: Any = None
37
+ result_for_model: str = ""
38
+ result_for_display: str | None = None
39
+ is_error: bool = False
40
+
41
+
42
+ @dataclass
43
+ class ToolContext:
44
+ """Context passed to tool execution."""
45
+
46
+ cwd: str = "."
47
+ messages: list[Message] = field(default_factory=list)
48
+ abort_signal: Any | None = None
49
+ session_id: str = ""
50
+ env: dict[str, str] = field(default_factory=dict)
51
+ on_event: ToolEventCallback | None = None
52
+ tool_config: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ CanUseToolFn = Callable[
56
+ ["Tool", dict[str, Any], "ToolContext"],
57
+ Coroutine[Any, Any, PermissionResult],
58
+ ]
59
+
60
+
61
+ class Tool(ABC):
62
+ """Base class for all CrabCode tools."""
63
+
64
+ name: str = ""
65
+ description: str = ""
66
+ input_schema: dict[str, Any] = {}
67
+ is_read_only: bool = False
68
+ is_concurrency_safe: bool = False
69
+ is_enabled: bool = True
70
+
71
+ _cached_prompt: str | None = None
72
+ _background_task: asyncio.Task[Any] | None = None
73
+ _setup_context: ToolContext | None = None
74
+
75
+ @abstractmethod
76
+ async def call(
77
+ self,
78
+ tool_input: dict[str, Any],
79
+ context: ToolContext,
80
+ ) -> ToolResult:
81
+ """Execute the tool with given input."""
82
+ ...
83
+
84
+ async def setup(self, context: ToolContext) -> None:
85
+ """Called once during session init. Override for heavy initialization."""
86
+ self._setup_context = context
87
+
88
+ def emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
89
+ """Emit a tool event (progress, ready, error, etc.)."""
90
+ if self._setup_context and self._setup_context.on_event:
91
+ self._setup_context.on_event(self.name, event_type, data or {})
92
+
93
+ async def wait_ready(self) -> None:
94
+ """Wait for background initialization to complete."""
95
+ if self._background_task and not self._background_task.done():
96
+ await self._background_task
97
+
98
+ async def get_prompt(self, **kwargs: Any) -> str:
99
+ """Return the tool description for the system prompt / API tools list."""
100
+ return self.description
101
+
102
+ async def validate_input(
103
+ self, tool_input: dict[str, Any]
104
+ ) -> str | None:
105
+ """Validate input. Return error string or None if valid."""
106
+ return None
107
+
108
+ async def check_permissions(
109
+ self,
110
+ tool_input: dict[str, Any],
111
+ context: ToolContext,
112
+ ) -> PermissionResult:
113
+ """Check if the tool can be used with the given input."""
114
+ return PermissionResult()
115
+
116
+ def to_api_schema(self) -> dict[str, Any]:
117
+ """Convert to API tool schema for model requests.
118
+
119
+ Uses the detailed prompt from get_prompt() when available,
120
+ falling back to the short description.
121
+ """
122
+ return {
123
+ "name": self.name,
124
+ "description": self._cached_prompt or self.description,
125
+ "input_schema": self.input_schema,
126
+ }
127
+
128
+ async def resolve_prompt(self, **kwargs: Any) -> None:
129
+ """Pre-resolve the detailed prompt for use in to_api_schema().
130
+
131
+ Call this once during setup so to_api_schema() can include the
132
+ full tool description without being async.
133
+ """
134
+ prompt = await self.get_prompt(**kwargs)
135
+ if prompt and prompt != self.description:
136
+ self._cached_prompt = prompt
137
+ else:
138
+ self._cached_prompt = None