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,392 @@
1
+ """Core session — the main interface between frontends and the engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any, AsyncGenerator
8
+
9
+ from crabcode_core.types.config import CrabCodeSettings
10
+ from crabcode_core.types.event import (
11
+ CompactEvent,
12
+ CoreEvent,
13
+ PermissionResponseEvent,
14
+ )
15
+ from crabcode_core.types.message import Message
16
+ from crabcode_core.types.tool import Tool, ToolEventCallback
17
+
18
+
19
+ class CoreSession:
20
+ """Main entry point for frontends to interact with CrabCode.
21
+
22
+ Holds conversation state, tools, and configuration.
23
+ Frontends create a CoreSession and call send_message() to get
24
+ an async stream of CoreEvents.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ cwd: str = ".",
30
+ settings: CrabCodeSettings | None = None,
31
+ tools: list[Tool] | None = None,
32
+ ):
33
+ self.cwd = os.path.abspath(cwd)
34
+ self.settings = settings or CrabCodeSettings()
35
+ self.messages: list[Message] = []
36
+ self.tools: list[Tool] = tools or []
37
+ self.session_id: str = ""
38
+ self._permission_queue: asyncio.Queue[PermissionResponseEvent] = asyncio.Queue()
39
+ self._abort_controller: asyncio.Event = asyncio.Event()
40
+
41
+ self.skills: list = []
42
+ self.on_tool_event: ToolEventCallback | None = None
43
+
44
+ self._api_adapter: Any = None
45
+ self._session_storage: Any = None
46
+ self._permission_manager: Any = None
47
+ self._mcp_manager: Any = None
48
+ self._prompt_profile: Any = None
49
+ self._initialized = False
50
+ self._current_model_name: str | None = None
51
+ self.compact_count: int = 0
52
+
53
+ async def initialize(self) -> None:
54
+ """Late initialization: set up API adapter, load tools, MCP, etc."""
55
+ if self._initialized:
56
+ return
57
+
58
+ from crabcode_core.api import create_adapter
59
+ from crabcode_core.config.manager import ConfigManager
60
+ from crabcode_core.mcp.client import McpManager
61
+ from crabcode_core.mcp.config import load_mcp_configs
62
+ from crabcode_core.permissions.manager import PermissionManager
63
+ from crabcode_core.session.storage import SessionStorage, generate_session_id
64
+ from crabcode_core.tools import get_default_tools
65
+
66
+ config_mgr = ConfigManager(cwd=self.cwd)
67
+ file_settings = config_mgr.load()
68
+
69
+ merged = self.settings
70
+
71
+ for key, val in file_settings.env.items():
72
+ os.environ.setdefault(key, val)
73
+
74
+ if file_settings.api.provider and not self.settings.api.provider:
75
+ merged.api.provider = file_settings.api.provider
76
+ if file_settings.api.model and not self.settings.api.model:
77
+ merged.api.model = file_settings.api.model
78
+ if file_settings.api.base_url and not self.settings.api.base_url:
79
+ merged.api.base_url = file_settings.api.base_url
80
+ if file_settings.api.api_key_env and not self.settings.api.api_key_env:
81
+ merged.api.api_key_env = file_settings.api.api_key_env
82
+ if file_settings.api.format and not self.settings.api.format:
83
+ merged.api.format = file_settings.api.format
84
+ if file_settings.api.thinking_enabled is False and self.settings.api.thinking_enabled:
85
+ merged.api.thinking_enabled = file_settings.api.thinking_enabled
86
+ if file_settings.api.max_tokens != 16384 and self.settings.api.max_tokens == 16384:
87
+ merged.api.max_tokens = file_settings.api.max_tokens
88
+
89
+ if file_settings.models:
90
+ for name, cfg in file_settings.models.items():
91
+ merged.models.setdefault(name, cfg)
92
+ if file_settings.default_model and not merged.default_model:
93
+ merged.default_model = file_settings.default_model
94
+
95
+ if file_settings.extra_tools and not self.settings.extra_tools:
96
+ merged.extra_tools = file_settings.extra_tools
97
+ if file_settings.tool_settings and not self.settings.tool_settings:
98
+ merged.tool_settings = file_settings.tool_settings
99
+ elif file_settings.tool_settings:
100
+ for name, cfg in file_settings.tool_settings.items():
101
+ merged.tool_settings.setdefault(name, {}).update(cfg)
102
+
103
+ # Keep a /model switch that ran before the first initialize() (late init).
104
+ chosen = self._current_model_name
105
+ if chosen is None or chosen not in merged.models:
106
+ chosen = merged.default_model
107
+ self._current_model_name = chosen
108
+ active_api_config = merged.get_api_config(self._current_model_name)
109
+ self._api_adapter = create_adapter(active_api_config)
110
+
111
+ if not self.tools:
112
+ self.tools = get_default_tools()
113
+
114
+ self.session_id = generate_session_id()
115
+ self._session_storage = SessionStorage(self.cwd, self.session_id)
116
+
117
+ self._permission_manager = PermissionManager(
118
+ settings=merged.permissions,
119
+ )
120
+
121
+ mcp_configs = load_mcp_configs(self.cwd)
122
+ all_mcp_configs = {**mcp_configs}
123
+ for name, cfg in merged.mcp_servers.items():
124
+ if name not in all_mcp_configs:
125
+ all_mcp_configs[name] = cfg
126
+
127
+ if all_mcp_configs:
128
+ self._mcp_manager = McpManager()
129
+ mcp_tools = await self._mcp_manager.connect(all_mcp_configs)
130
+ existing_names = {t.name for t in self.tools}
131
+ for mcp_tool in mcp_tools:
132
+ if mcp_tool.name not in existing_names:
133
+ self.tools.append(mcp_tool)
134
+
135
+ import importlib
136
+ for tool_path in merged.extra_tools:
137
+ try:
138
+ module_path, class_name = tool_path.rsplit(".", 1)
139
+ mod = importlib.import_module(module_path)
140
+ tool_cls = getattr(mod, class_name)
141
+ self.tools.append(tool_cls())
142
+ except Exception:
143
+ pass
144
+
145
+ from crabcode_core.types.tool import ToolContext as _ToolContext
146
+
147
+ async def _setup_tool(tool: Tool) -> None:
148
+ ctx = _ToolContext(
149
+ cwd=self.cwd,
150
+ env=merged.env,
151
+ on_event=self.on_tool_event,
152
+ tool_config=merged.tool_settings.get(tool.name, {}),
153
+ )
154
+ await tool.setup(ctx)
155
+
156
+ await asyncio.gather(*(_setup_tool(t) for t in self.tools))
157
+
158
+ from crabcode_core.prompts.profile import PromptProfile
159
+ from crabcode_core.tools.agent import AgentTool
160
+
161
+ if self.settings.prompt_profile:
162
+ self._prompt_profile = PromptProfile(**self.settings.prompt_profile)
163
+
164
+ has_agent = any(isinstance(t, AgentTool) for t in self.tools)
165
+ if not has_agent:
166
+ sub_tools = list(self.tools)
167
+ agent_cfg = merged.agent
168
+ self.tools.append(AgentTool(
169
+ api_adapter=self._api_adapter,
170
+ tools=sub_tools,
171
+ prompt_profile=self._prompt_profile,
172
+ max_turns=agent_cfg.max_turns,
173
+ timeout=agent_cfg.timeout,
174
+ max_output_chars=agent_cfg.max_output_chars,
175
+ max_display_lines=merged.display.get_max_lines("Agent"),
176
+ ))
177
+
178
+ from crabcode_core.skills.loader import load_skills
179
+ from crabcode_core.tools.skill import SkillTool
180
+
181
+ self.skills = load_skills(self.cwd)
182
+ if self.skills:
183
+ self.tools.append(SkillTool(self.skills))
184
+
185
+ await asyncio.gather(*(t.resolve_prompt() for t in self.tools))
186
+
187
+ self._initialized = True
188
+
189
+ async def send_message(
190
+ self,
191
+ text: str,
192
+ max_turns: int = 0,
193
+ ) -> AsyncGenerator[CoreEvent, None]:
194
+ """Send a user message and stream back events."""
195
+ await self.initialize()
196
+ self._abort_controller.clear()
197
+
198
+ from crabcode_core.compact.compact import should_auto_compact, compact_conversation
199
+ from crabcode_core.prompts.context import get_system_context, get_user_context
200
+ from crabcode_core.prompts.profile import PromptProfile
201
+ from crabcode_core.prompts.system import get_system_prompt
202
+ from crabcode_core.query.loop import QueryParams, query_loop
203
+ from crabcode_core.types.event import CompactEvent, TurnCompleteEvent
204
+ from crabcode_core.types.message import create_user_message
205
+ from crabcode_core.types.tool import ToolContext
206
+
207
+ user_msg = create_user_message(content=text)
208
+ self.messages.append(user_msg)
209
+
210
+ if self._session_storage:
211
+ self._session_storage.append_message(user_msg)
212
+
213
+ compact_kwargs: dict[str, Any] = {}
214
+ if self.settings.max_context_length is not None:
215
+ compact_kwargs["threshold"] = self.settings.max_context_length
216
+
217
+ if self.settings.auto_compact_enabled and should_auto_compact(self.messages, **compact_kwargs):
218
+ compact_result = await compact_conversation(
219
+ self.messages,
220
+ api_adapter=self._api_adapter,
221
+ )
222
+ if compact_result:
223
+ old_count = len(self.messages)
224
+ self.messages = compact_result
225
+ self.compact_count += 1
226
+ yield CompactEvent(
227
+ summary="Conversation auto-compacted",
228
+ messages_before=old_count,
229
+ messages_after=len(self.messages),
230
+ )
231
+
232
+ tool_names = [t.name for t in self.tools]
233
+ active_api_cfg = self.settings.get_api_config(self._current_model_name)
234
+ model = active_api_cfg.model or "claude-sonnet-4-20250514"
235
+
236
+ profile: PromptProfile | None = None
237
+ if self.settings.prompt_profile:
238
+ profile = PromptProfile(**self.settings.prompt_profile)
239
+
240
+ system_prompt = get_system_prompt(
241
+ enabled_tools=tool_names,
242
+ model_id=model,
243
+ cwd=self.cwd,
244
+ language=self.settings.language,
245
+ profile=profile,
246
+ )
247
+ system_context = get_system_context(self.cwd)
248
+ user_context = get_user_context(self.cwd)
249
+
250
+ tool_context = ToolContext(
251
+ cwd=self.cwd,
252
+ messages=self.messages,
253
+ session_id=self.session_id,
254
+ env=self.settings.env,
255
+ )
256
+
257
+ params = QueryParams(
258
+ messages=list(self.messages),
259
+ system_prompt=system_prompt,
260
+ user_context=user_context,
261
+ system_context=system_context,
262
+ tools=self.tools,
263
+ tool_context=tool_context,
264
+ api_adapter=self._api_adapter,
265
+ max_turns=max_turns or 0,
266
+ permission_manager=self._permission_manager,
267
+ permission_queue=self._permission_queue,
268
+ )
269
+
270
+ pre_loop_count = len(self.messages)
271
+
272
+ async for event in query_loop(params):
273
+ if isinstance(event, TurnCompleteEvent):
274
+ self.messages = params.messages
275
+
276
+ if self._session_storage:
277
+ for msg in self.messages[pre_loop_count:]:
278
+ self._session_storage.append_message(msg)
279
+
280
+ yield event
281
+
282
+ async def respond_permission(self, response: PermissionResponseEvent) -> None:
283
+ await self._permission_queue.put(response)
284
+
285
+ async def interrupt(self) -> None:
286
+ self._abort_controller.set()
287
+
288
+ def record_partial_assistant_output(self, text: str) -> None:
289
+ """Append assistant text when a turn stops mid-stream so the next round keeps context."""
290
+ if not text or not text.strip():
291
+ return
292
+ from crabcode_core.types.message import TextBlock, create_assistant_message
293
+
294
+ assistant_msg = create_assistant_message(content=[TextBlock(text=text)])
295
+ self.messages.append(assistant_msg)
296
+ if self._session_storage:
297
+ self._session_storage.append_message(assistant_msg)
298
+
299
+ def new_session(self) -> str:
300
+ """Start a fresh session, preserving tools and config. Returns the new session ID."""
301
+ from crabcode_core.session.storage import SessionStorage, generate_session_id
302
+
303
+ self.messages.clear()
304
+ self.session_id = generate_session_id()
305
+ self._session_storage = SessionStorage(self.cwd, self.session_id)
306
+ return self.session_id
307
+
308
+ async def compact(self) -> None:
309
+ """Manually trigger conversation compaction."""
310
+ from crabcode_core.compact.compact import compact_conversation
311
+ result = await compact_conversation(
312
+ self.messages,
313
+ api_adapter=self._api_adapter,
314
+ )
315
+ if result:
316
+ self.messages = result
317
+ self.compact_count += 1
318
+
319
+ def list_models(self) -> dict[str, str]:
320
+ """Return a dict of {name -> description} for all configured named models.
321
+
322
+ The description is "<provider>/<model>" or just the model id if available.
323
+ """
324
+ result: dict[str, str] = {}
325
+ for name, cfg in self.settings.models.items():
326
+ parts = []
327
+ if cfg.provider:
328
+ parts.append(cfg.provider)
329
+ if cfg.model:
330
+ parts.append(cfg.model)
331
+ result[name] = "/".join(parts) if parts else "(no model set)"
332
+ return result
333
+
334
+ def switch_model(self, name: str) -> bool:
335
+ """Switch to a named model defined in settings.models.
336
+
337
+ Returns True on success, False if the name is not found.
338
+ Must be called after initialize().
339
+ """
340
+ if name not in self.settings.models:
341
+ return False
342
+
343
+ from crabcode_core.api import create_adapter
344
+ from crabcode_core.tools.agent import AgentTool
345
+
346
+ api_config = self.settings.models[name]
347
+ self._api_adapter = create_adapter(api_config)
348
+ self._current_model_name = name
349
+
350
+ for tool in self.tools:
351
+ if isinstance(tool, AgentTool):
352
+ tool.api_adapter = self._api_adapter
353
+
354
+ return True
355
+
356
+ async def resume(self, session_id: str) -> bool:
357
+ """Resume a previous session by loading its messages."""
358
+ from crabcode_core.session.storage import SessionStorage
359
+ from crabcode_core.types.message import (
360
+ create_assistant_message,
361
+ create_user_message,
362
+ deserialize_content,
363
+ )
364
+
365
+ storage = SessionStorage(self.cwd, session_id)
366
+ raw_messages = storage.load_messages()
367
+
368
+ if not raw_messages:
369
+ return False
370
+
371
+ self.session_id = session_id
372
+ self._session_storage = storage
373
+ self.messages.clear()
374
+
375
+ for raw in raw_messages:
376
+ role = raw.get("type", "user")
377
+ content = deserialize_content(raw.get("content", ""))
378
+ msg_uuid = raw.get("uuid")
379
+ parent_uuid = raw.get("parent_uuid")
380
+
381
+ kwargs: dict[str, Any] = {}
382
+ if msg_uuid:
383
+ kwargs["uuid"] = msg_uuid
384
+ if parent_uuid:
385
+ kwargs["parent_uuid"] = parent_uuid
386
+
387
+ if role == "user":
388
+ self.messages.append(create_user_message(content=content, **kwargs))
389
+ elif role == "assistant":
390
+ self.messages.append(create_assistant_message(content=content, **kwargs))
391
+
392
+ return True
@@ -0,0 +1,4 @@
1
+ from crabcode_core.mcp.client import McpManager, McpToolWrapper
2
+ from crabcode_core.mcp.config import load_mcp_configs
3
+
4
+ __all__ = ["McpManager", "McpToolWrapper", "load_mcp_configs"]
@@ -0,0 +1,163 @@
1
+ """MCP client — connects to MCP servers and loads tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ from typing import Any
10
+
11
+ from crabcode_core.types.config import McpServerConfig
12
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
13
+
14
+
15
+ def _normalize_name(name: str) -> str:
16
+ """Normalize a server/tool name to a valid identifier."""
17
+ return re.sub(r'[^a-zA-Z0-9_]', '_', name)
18
+
19
+
20
+ def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
21
+ """Build the prefixed MCP tool name."""
22
+ return f"mcp__{_normalize_name(server_name)}__{_normalize_name(tool_name)}"
23
+
24
+
25
+ class McpToolWrapper(Tool):
26
+ """Wraps an MCP tool as a CrabCode Tool."""
27
+
28
+ def __init__(
29
+ self,
30
+ server_name: str,
31
+ tool_name: str,
32
+ description: str,
33
+ schema: dict[str, Any],
34
+ client: Any,
35
+ ):
36
+ self.name = build_mcp_tool_name(server_name, tool_name)
37
+ self.description = description
38
+ self.input_schema = schema
39
+ self.is_read_only = False
40
+ self.is_concurrency_safe = True
41
+ self.is_enabled = True
42
+
43
+ self._server_name = server_name
44
+ self._original_name = tool_name
45
+ self._client = client
46
+
47
+ async def call(
48
+ self,
49
+ tool_input: dict[str, Any],
50
+ context: ToolContext,
51
+ ) -> ToolResult:
52
+ try:
53
+ result = await self._client.call_tool(self._original_name, tool_input)
54
+
55
+ text_parts: list[str] = []
56
+ if hasattr(result, "content"):
57
+ for block in result.content:
58
+ if hasattr(block, "text"):
59
+ text_parts.append(block.text)
60
+
61
+ output = "\n".join(text_parts) if text_parts else str(result)
62
+ is_error = getattr(result, "isError", False)
63
+
64
+ return ToolResult(
65
+ data=result,
66
+ result_for_model=output,
67
+ is_error=is_error,
68
+ )
69
+ except Exception as e:
70
+ return ToolResult(
71
+ result_for_model=f"MCP tool error: {e}",
72
+ is_error=True,
73
+ )
74
+
75
+
76
+ class McpManager:
77
+ """Manages MCP server connections and tool discovery."""
78
+
79
+ def __init__(self) -> None:
80
+ self._connections: dict[str, Any] = {}
81
+ self._tools: list[McpToolWrapper] = []
82
+
83
+ async def connect(
84
+ self,
85
+ servers: dict[str, McpServerConfig],
86
+ ) -> list[McpToolWrapper]:
87
+ """Connect to all configured MCP servers and fetch tools."""
88
+ all_tools: list[McpToolWrapper] = []
89
+
90
+ for name, config in servers.items():
91
+ if config.disabled:
92
+ continue
93
+
94
+ try:
95
+ tools = await self._connect_server(name, config)
96
+ all_tools.extend(tools)
97
+ except Exception:
98
+ pass
99
+
100
+ self._tools = all_tools
101
+ return all_tools
102
+
103
+ async def _connect_server(
104
+ self,
105
+ name: str,
106
+ config: McpServerConfig,
107
+ ) -> list[McpToolWrapper]:
108
+ """Connect to a single MCP server."""
109
+ try:
110
+ from mcp import ClientSession, StdioServerParameters
111
+ from mcp.client.stdio import stdio_client
112
+ except ImportError:
113
+ return []
114
+
115
+ if not config.command:
116
+ return []
117
+
118
+ env = {**os.environ, **config.env}
119
+ server_params = StdioServerParameters(
120
+ command=config.command[0],
121
+ args=config.command[1:] if len(config.command) > 1 else [],
122
+ env=env,
123
+ )
124
+
125
+ try:
126
+ read_stream, write_stream = await asyncio.wait_for(
127
+ stdio_client(server_params).__aenter__(),
128
+ timeout=30,
129
+ )
130
+ session = ClientSession(read_stream, write_stream)
131
+ await session.initialize()
132
+ self._connections[name] = session
133
+
134
+ tools_response = await session.list_tools()
135
+ tools: list[McpToolWrapper] = []
136
+ for tool_info in tools_response.tools:
137
+ wrapper = McpToolWrapper(
138
+ server_name=name,
139
+ tool_name=tool_info.name,
140
+ description=tool_info.description or "",
141
+ schema=tool_info.inputSchema or {"type": "object", "properties": {}},
142
+ client=session,
143
+ )
144
+ tools.append(wrapper)
145
+
146
+ return tools
147
+
148
+ except Exception:
149
+ return []
150
+
151
+ @property
152
+ def tools(self) -> list[McpToolWrapper]:
153
+ return self._tools
154
+
155
+ async def disconnect_all(self) -> None:
156
+ """Disconnect from all MCP servers."""
157
+ for session in self._connections.values():
158
+ try:
159
+ await session.__aexit__(None, None, None)
160
+ except Exception:
161
+ pass
162
+ self._connections.clear()
163
+ self._tools.clear()
@@ -0,0 +1,52 @@
1
+ """MCP configuration — multi-scope server config loading and merging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from crabcode_core.types.config import McpServerConfig
10
+
11
+
12
+ def load_mcp_configs(cwd: str) -> dict[str, McpServerConfig]:
13
+ """Load and merge MCP server configurations from multiple scopes.
14
+
15
+ Scopes (later overrides earlier):
16
+ 1. User (~/.crabcode/mcp_servers.json)
17
+ 2. Project (<project>/.crabcode/mcp_servers.json)
18
+ 3. Local (<project>/.crabcode/mcp_servers.local.json)
19
+ """
20
+ merged: dict[str, dict[str, Any]] = {}
21
+ home = Path.home()
22
+
23
+ paths = [
24
+ home / ".crabcode" / "mcp_servers.json",
25
+ home / ".claude" / "mcp_servers.json",
26
+ Path(cwd).resolve() / ".crabcode" / "mcp_servers.json",
27
+ Path(cwd).resolve() / ".claude" / "mcp_servers.json",
28
+ Path(cwd).resolve() / ".crabcode" / "mcp_servers.local.json",
29
+ ]
30
+
31
+ for path in paths:
32
+ if not path.exists():
33
+ continue
34
+ try:
35
+ raw = json.loads(path.read_text(errors="replace"))
36
+ if isinstance(raw, dict):
37
+ servers = raw.get("mcpServers", raw)
38
+ if isinstance(servers, dict):
39
+ for name, config in servers.items():
40
+ if isinstance(config, dict):
41
+ merged[name] = {**merged.get(name, {}), **config}
42
+ except (json.JSONDecodeError, OSError):
43
+ continue
44
+
45
+ result: dict[str, McpServerConfig] = {}
46
+ for name, raw in merged.items():
47
+ try:
48
+ result[name] = McpServerConfig.model_validate(raw)
49
+ except Exception:
50
+ pass
51
+
52
+ return result
@@ -0,0 +1,3 @@
1
+ from crabcode_core.permissions.manager import PermissionManager, PermissionMode
2
+
3
+ __all__ = ["PermissionManager", "PermissionMode"]