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,442 @@
1
+ """Query loop — the agentic turn loop, heart of the system.
2
+
3
+ Ported from src/query.ts. Sends messages to the API, processes tool calls,
4
+ and loops until the model stops using tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, AsyncGenerator
13
+
14
+ from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk
15
+ from crabcode_core.types.event import (
16
+ CompactEvent,
17
+ CoreEvent,
18
+ ErrorEvent,
19
+ PermissionRequestEvent,
20
+ StreamModeEvent,
21
+ StreamTextEvent,
22
+ ThinkingEvent,
23
+ ToolResultEvent,
24
+ ToolUseEvent,
25
+ TurnCompleteEvent,
26
+ )
27
+ from crabcode_core.types.message import (
28
+ AssistantMessage,
29
+ ContentBlock,
30
+ Message,
31
+ TextBlock,
32
+ ThinkingBlock,
33
+ ToolUseBlock,
34
+ create_assistant_message,
35
+ create_tool_result_message,
36
+ create_user_message,
37
+ )
38
+ from crabcode_core.types.tool import PermissionBehavior, Tool, ToolContext, ToolResult
39
+
40
+
41
+ @dataclass
42
+ class QueryParams:
43
+ messages: list[Message]
44
+ system_prompt: list[str]
45
+ user_context: dict[str, str]
46
+ system_context: dict[str, str]
47
+ tools: list[Tool]
48
+ tool_context: ToolContext
49
+ api_adapter: APIAdapter
50
+ max_turns: int = 0
51
+ permission_manager: Any = None
52
+ permission_queue: asyncio.Queue | None = None
53
+
54
+
55
+ def _append_system_context(
56
+ system_prompt: list[str],
57
+ context: dict[str, str],
58
+ ) -> list[str]:
59
+ """Append system context entries to the end of system prompt."""
60
+ if not context:
61
+ return system_prompt
62
+ extra = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
63
+ if extra:
64
+ return [*system_prompt, extra]
65
+ return system_prompt
66
+
67
+
68
+ def _prepend_user_context(
69
+ messages: list[Message],
70
+ user_context: dict[str, str],
71
+ ) -> list[Message]:
72
+ """Prepend user context as a meta user message at the start."""
73
+ if not user_context:
74
+ return messages
75
+
76
+ ctx_parts = []
77
+ for key, value in user_context.items():
78
+ ctx_parts.append(f"# {key}\n{value}")
79
+ ctx_text = "\n".join(ctx_parts)
80
+
81
+ meta_msg = create_user_message(
82
+ content=(
83
+ f"<system-reminder>\n"
84
+ f"As you answer the user's questions, you can use the following context:\n"
85
+ f"{ctx_text}\n\n"
86
+ f"IMPORTANT: this context may or may not be relevant to your tasks. "
87
+ f"You should not respond to this context unless it is highly relevant "
88
+ f"to your task.\n</system-reminder>\n"
89
+ ),
90
+ )
91
+
92
+ return [meta_msg, *messages]
93
+
94
+
95
+ def _find_tool(tools: list[Tool], name: str) -> Tool | None:
96
+ for tool in tools:
97
+ if tool.name == name:
98
+ return tool
99
+ return None
100
+
101
+
102
+ async def _run_tools(
103
+ tool_use_blocks: list[ToolUseBlock],
104
+ assistant_msg: AssistantMessage,
105
+ tools: list[Tool],
106
+ context: ToolContext,
107
+ ) -> list[tuple[Message, CoreEvent]]:
108
+ """Execute tool calls and return (result_message, event) pairs.
109
+
110
+ Runs concurrency-safe tools in parallel, others sequentially.
111
+ """
112
+ import asyncio
113
+
114
+ results: list[tuple[Message, CoreEvent]] = []
115
+
116
+ safe: list[ToolUseBlock] = []
117
+ unsafe: list[ToolUseBlock] = []
118
+ for block in tool_use_blocks:
119
+ tool = _find_tool(tools, block.name)
120
+ if tool and tool.is_concurrency_safe:
121
+ safe.append(block)
122
+ else:
123
+ unsafe.append(block)
124
+
125
+ async def execute_one(block: ToolUseBlock) -> tuple[Message, CoreEvent]:
126
+ tool = _find_tool(tools, block.name)
127
+ if not tool:
128
+ msg = create_tool_result_message(
129
+ tool_use_id=block.id,
130
+ result=f"Error: unknown tool '{block.name}'",
131
+ is_error=True,
132
+ source_tool_assistant_uuid=assistant_msg.uuid,
133
+ )
134
+ event = ToolResultEvent(
135
+ tool_use_id=block.id,
136
+ tool_name=block.name,
137
+ result=f"Error: unknown tool '{block.name}'",
138
+ is_error=True,
139
+ )
140
+ return msg, event
141
+
142
+ validation_error = await tool.validate_input(block.input)
143
+ if validation_error:
144
+ msg = create_tool_result_message(
145
+ tool_use_id=block.id,
146
+ result=f"Validation error: {validation_error}",
147
+ is_error=True,
148
+ source_tool_assistant_uuid=assistant_msg.uuid,
149
+ )
150
+ event = ToolResultEvent(
151
+ tool_use_id=block.id,
152
+ tool_name=block.name,
153
+ result=f"Validation error: {validation_error}",
154
+ is_error=True,
155
+ )
156
+ return msg, event
157
+
158
+ try:
159
+ result = await tool.call(block.input, context)
160
+ except Exception as e:
161
+ result = ToolResult(
162
+ result_for_model=f"Error executing tool: {e}",
163
+ is_error=True,
164
+ )
165
+
166
+ msg = create_tool_result_message(
167
+ tool_use_id=block.id,
168
+ result=result.result_for_model,
169
+ is_error=result.is_error,
170
+ source_tool_assistant_uuid=assistant_msg.uuid,
171
+ )
172
+ event = ToolResultEvent(
173
+ tool_use_id=block.id,
174
+ tool_name=block.name,
175
+ result=result.result_for_model,
176
+ is_error=result.is_error,
177
+ result_for_display=result.result_for_display,
178
+ )
179
+ return msg, event
180
+
181
+ if safe:
182
+ safe_results = await asyncio.gather(*(execute_one(b) for b in safe))
183
+ results.extend(safe_results)
184
+
185
+ for block in unsafe:
186
+ result = await execute_one(block)
187
+ results.append(result)
188
+
189
+ return results
190
+
191
+
192
+ async def query_loop(
193
+ params: QueryParams,
194
+ ) -> AsyncGenerator[CoreEvent, None]:
195
+ """The main agentic turn loop.
196
+
197
+ Sends messages to the API, processes streaming response, executes
198
+ tool calls, and loops until no more tool calls are made.
199
+ """
200
+ messages = list(params.messages)
201
+ turn_count = 0
202
+ total_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
203
+
204
+ while True:
205
+ turn_count += 1
206
+
207
+ full_system = _append_system_context(
208
+ params.system_prompt, params.system_context
209
+ )
210
+ messages_for_api = _prepend_user_context(messages, params.user_context)
211
+
212
+ tool_schemas = [t.to_api_schema() for t in params.tools if t.is_enabled]
213
+
214
+ model_config = ModelConfig(
215
+ model=params.api_adapter.config.model if hasattr(params.api_adapter, 'config') else "claude-sonnet-4-20250514",
216
+ max_tokens=16384,
217
+ timeout=getattr(params.api_adapter.config, 'timeout', 300) if hasattr(params.api_adapter, 'config') else 300,
218
+ )
219
+
220
+ assistant_content: list[ContentBlock] = []
221
+ tool_use_blocks: list[ToolUseBlock] = []
222
+ current_text = ""
223
+ current_thinking = ""
224
+ current_tool: dict[str, str] = {}
225
+ emitted_mode: str = ""
226
+
227
+ def _flush_thinking_block() -> None:
228
+ nonlocal current_thinking
229
+ if not current_thinking:
230
+ return
231
+ assistant_content.append(ThinkingBlock(thinking=current_thinking))
232
+ current_thinking = ""
233
+
234
+ yield StreamModeEvent(mode="requesting")
235
+
236
+ try:
237
+ # Wrap stream with timeout to avoid hanging indefinitely
238
+ async def _stream_with_timeout():
239
+ async for chunk in params.api_adapter.stream_message(
240
+ messages=messages_for_api,
241
+ system=full_system,
242
+ tools=tool_schemas,
243
+ config=model_config,
244
+ ):
245
+ yield chunk
246
+
247
+ stream = _stream_with_timeout()
248
+ chunk = None
249
+ while True:
250
+ try:
251
+ chunk = await asyncio.wait_for(
252
+ stream.__anext__(),
253
+ timeout=model_config.timeout,
254
+ )
255
+ except StopAsyncIteration:
256
+ break
257
+ except asyncio.TimeoutError:
258
+ yield ErrorEvent(
259
+ message=f"API request timed out after {model_config.timeout}s",
260
+ recoverable=True,
261
+ )
262
+ return
263
+ if chunk.type == "text":
264
+ _flush_thinking_block()
265
+ if emitted_mode != "responding":
266
+ yield StreamModeEvent(mode="responding")
267
+ emitted_mode = "responding"
268
+ yield StreamTextEvent(text=chunk.text)
269
+ current_text += chunk.text
270
+
271
+ elif chunk.type == "thinking":
272
+ if emitted_mode != "thinking":
273
+ yield StreamModeEvent(mode="thinking")
274
+ emitted_mode = "thinking"
275
+ yield ThinkingEvent(text=chunk.text)
276
+ current_thinking += chunk.text
277
+
278
+ elif chunk.type == "tool_use_start":
279
+ _flush_thinking_block()
280
+ if current_text:
281
+ assistant_content.append(TextBlock(text=current_text))
282
+ current_text = ""
283
+ if emitted_mode != "tool-input":
284
+ yield StreamModeEvent(mode="tool-input")
285
+ emitted_mode = "tool-input"
286
+ current_tool = {
287
+ "id": chunk.tool_use_id,
288
+ "name": chunk.tool_name,
289
+ "input_json": "",
290
+ }
291
+
292
+ elif chunk.type == "tool_use_delta":
293
+ current_tool["input_json"] = current_tool.get("input_json", "") + chunk.tool_input_json
294
+
295
+ elif chunk.type == "tool_use_end":
296
+ input_json = chunk.tool_input_json or current_tool.get("input_json", "{}")
297
+ try:
298
+ tool_input = json.loads(input_json)
299
+ except json.JSONDecodeError:
300
+ tool_input = {}
301
+
302
+ block = ToolUseBlock(
303
+ id=chunk.tool_use_id or current_tool.get("id", ""),
304
+ name=chunk.tool_name or current_tool.get("name", ""),
305
+ input=tool_input,
306
+ )
307
+ assistant_content.append(block)
308
+ tool_use_blocks.append(block)
309
+
310
+ yield ToolUseEvent(
311
+ tool_name=block.name,
312
+ tool_input=block.input,
313
+ tool_use_id=block.id,
314
+ )
315
+ current_tool = {}
316
+
317
+ elif chunk.type == "message_start":
318
+ if chunk.usage:
319
+ total_usage["input_tokens"] += chunk.usage.get("input_tokens", 0)
320
+
321
+ elif chunk.type == "message_delta":
322
+ if chunk.usage:
323
+ total_usage["output_tokens"] += chunk.usage.get("output_tokens", 0)
324
+
325
+ elif chunk.type == "message_stop":
326
+ pass
327
+
328
+ elif chunk.type == "error":
329
+ yield ErrorEvent(
330
+ message=chunk.error,
331
+ recoverable=False,
332
+ )
333
+ return
334
+
335
+ except Exception as e:
336
+ yield ErrorEvent(message=str(e), recoverable=False)
337
+ return
338
+
339
+ _flush_thinking_block()
340
+ if current_text:
341
+ assistant_content.append(TextBlock(text=current_text))
342
+
343
+ if assistant_content:
344
+ assistant_msg = create_assistant_message(content=assistant_content)
345
+ messages.append(assistant_msg)
346
+ else:
347
+ yield TurnCompleteEvent(
348
+ reason="empty_response",
349
+ turn_count=turn_count,
350
+ usage=total_usage,
351
+ )
352
+ params.messages[:] = messages
353
+ return
354
+
355
+ if not tool_use_blocks:
356
+ yield TurnCompleteEvent(
357
+ reason="end_turn",
358
+ turn_count=turn_count,
359
+ usage=total_usage,
360
+ )
361
+ params.messages[:] = messages
362
+ return
363
+
364
+ approved_blocks: list[ToolUseBlock] = []
365
+
366
+ for block in tool_use_blocks:
367
+ tool = _find_tool(params.tools, block.name)
368
+
369
+ if not params.permission_manager or not params.permission_queue:
370
+ approved_blocks.append(block)
371
+ continue
372
+
373
+ if not tool:
374
+ approved_blocks.append(block)
375
+ continue
376
+
377
+ perm = params.permission_manager.check(tool, block.input)
378
+
379
+ if perm.behavior == PermissionBehavior.ALLOW:
380
+ approved_blocks.append(block)
381
+ elif perm.behavior == PermissionBehavior.DENY:
382
+ reason = perm.reason or "denied by rules"
383
+ msg = create_tool_result_message(
384
+ tool_use_id=block.id,
385
+ result=f"Permission denied: {reason}",
386
+ is_error=True,
387
+ source_tool_assistant_uuid=assistant_msg.uuid,
388
+ )
389
+ messages.append(msg)
390
+ yield ToolResultEvent(
391
+ tool_use_id=block.id,
392
+ tool_name=block.name,
393
+ result=f"Permission denied: {reason}",
394
+ is_error=True,
395
+ )
396
+ elif perm.behavior == PermissionBehavior.ASK:
397
+ yield PermissionRequestEvent(
398
+ tool_name=block.name,
399
+ tool_input=block.input,
400
+ tool_use_id=block.id,
401
+ )
402
+ response = await params.permission_queue.get()
403
+ if response.allowed:
404
+ if response.always_allow:
405
+ params.permission_manager.add_allow_rule(tool.name)
406
+ approved_blocks.append(block)
407
+ else:
408
+ msg = create_tool_result_message(
409
+ tool_use_id=block.id,
410
+ result=(
411
+ "Permission denied by user. Do not retry the same "
412
+ "tool call. Explain what you wanted to do and ask "
413
+ "for guidance."
414
+ ),
415
+ is_error=True,
416
+ source_tool_assistant_uuid=assistant_msg.uuid,
417
+ )
418
+ messages.append(msg)
419
+ yield ToolResultEvent(
420
+ tool_use_id=block.id,
421
+ tool_name=block.name,
422
+ result="Permission denied by user.",
423
+ is_error=True,
424
+ )
425
+
426
+ if approved_blocks:
427
+ yield StreamModeEvent(mode="tool-running")
428
+ tool_results = await _run_tools(
429
+ approved_blocks, assistant_msg, params.tools, params.tool_context
430
+ )
431
+ for msg, event in tool_results:
432
+ messages.append(msg)
433
+ yield event
434
+
435
+ if params.max_turns and turn_count >= params.max_turns:
436
+ yield TurnCompleteEvent(
437
+ reason="max_turns_reached",
438
+ turn_count=turn_count,
439
+ usage=total_usage,
440
+ )
441
+ params.messages[:] = messages
442
+ return
@@ -0,0 +1,3 @@
1
+ from crabcode_core.session.storage import SessionStorage, generate_session_id
2
+
3
+ __all__ = ["SessionStorage", "generate_session_id"]
@@ -0,0 +1,153 @@
1
+ """Session storage — JSONL-based conversation persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from crabcode_core.types.message import Message
14
+
15
+
16
+ def get_config_home() -> Path:
17
+ """Get the CrabCode config directory (~/.crabcode/)."""
18
+ return Path.home() / ".crabcode"
19
+
20
+
21
+ def get_projects_dir() -> Path:
22
+ """Get the projects directory for session storage."""
23
+ return get_config_home() / "projects"
24
+
25
+
26
+ def _sanitize_path(path: str) -> str:
27
+ """Sanitize a filesystem path for use as a directory name."""
28
+ sanitized = re.sub(r'[^\w\-.]', '_', path)
29
+ if len(sanitized) > 200:
30
+ sanitized = sanitized[:200]
31
+ return sanitized
32
+
33
+
34
+ def get_project_dir(cwd: str) -> Path:
35
+ """Get the project-specific session directory."""
36
+ return get_projects_dir() / _sanitize_path(os.path.abspath(cwd))
37
+
38
+
39
+ def get_transcript_path(cwd: str, session_id: str) -> Path:
40
+ """Get the path for a session transcript file."""
41
+ return get_project_dir(cwd) / f"{session_id}.jsonl"
42
+
43
+
44
+ def generate_session_id() -> str:
45
+ return str(uuid.uuid4())
46
+
47
+
48
+ class SessionStorage:
49
+ """Manages session persistence using JSONL files."""
50
+
51
+ def __init__(self, cwd: str, session_id: str | None = None):
52
+ self.cwd = cwd
53
+ self.session_id = session_id or generate_session_id()
54
+ self._transcript_path = get_transcript_path(cwd, self.session_id)
55
+ self._initialized = False
56
+ self._written_uuids: set[str] = set()
57
+
58
+ def _ensure_dir(self) -> None:
59
+ if not self._initialized:
60
+ self._transcript_path.parent.mkdir(parents=True, exist_ok=True)
61
+ self._initialized = True
62
+
63
+ def append_message(self, message: Message) -> None:
64
+ """Append a message to the session transcript (skips duplicates by uuid)."""
65
+ if message.uuid in self._written_uuids:
66
+ return
67
+ self._written_uuids.add(message.uuid)
68
+
69
+ self._ensure_dir()
70
+ entry = {
71
+ "type": message.role.value,
72
+ "uuid": message.uuid,
73
+ "parent_uuid": message.parent_uuid,
74
+ "timestamp": message.timestamp,
75
+ "content": message.content if isinstance(message.content, str) else [
76
+ block.model_dump() for block in message.content
77
+ ],
78
+ }
79
+ with open(self._transcript_path, "a") as f:
80
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
81
+
82
+ def load_messages(self) -> list[dict[str, Any]]:
83
+ """Load all messages from the session transcript (deduped by uuid)."""
84
+ if not self._transcript_path.exists():
85
+ return []
86
+
87
+ messages: list[dict[str, Any]] = []
88
+ seen: set[str] = set()
89
+ try:
90
+ with open(self._transcript_path) as f:
91
+ for line in f:
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ try:
96
+ entry = json.loads(line)
97
+ except json.JSONDecodeError:
98
+ continue
99
+ msg_uuid = entry.get("uuid", "")
100
+ if msg_uuid and msg_uuid in seen:
101
+ continue
102
+ if msg_uuid:
103
+ seen.add(msg_uuid)
104
+ messages.append(entry)
105
+ except Exception:
106
+ pass
107
+
108
+ self._written_uuids = seen
109
+ return messages
110
+
111
+ @staticmethod
112
+ def list_sessions(cwd: str) -> list[dict[str, Any]]:
113
+ """List all sessions for a given working directory."""
114
+ project_dir = get_project_dir(cwd)
115
+ if not project_dir.exists():
116
+ return []
117
+
118
+ sessions: list[dict[str, Any]] = []
119
+ for path in sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
120
+ session_id = path.stem
121
+ try:
122
+ stat = path.stat()
123
+ with open(path) as f:
124
+ first_line = f.readline().strip()
125
+ first_msg = json.loads(first_line) if first_line else {}
126
+
127
+ sessions.append({
128
+ "session_id": session_id,
129
+ "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
130
+ "size": stat.st_size,
131
+ "preview": _extract_preview(first_msg),
132
+ })
133
+ except Exception:
134
+ sessions.append({
135
+ "session_id": session_id,
136
+ "modified": "",
137
+ "size": 0,
138
+ "preview": "",
139
+ })
140
+
141
+ return sessions
142
+
143
+
144
+ def _extract_preview(msg: dict[str, Any]) -> str:
145
+ """Extract a short preview from a message entry."""
146
+ content = msg.get("content", "")
147
+ if isinstance(content, str):
148
+ return content[:100]
149
+ if isinstance(content, list) and content:
150
+ first = content[0]
151
+ if isinstance(first, dict) and "text" in first:
152
+ return first["text"][:100]
153
+ return ""
@@ -0,0 +1,5 @@
1
+ """Skills package — skill discovery and definitions."""
2
+
3
+ from crabcode_core.skills.loader import SkillDefinition, load_skills
4
+
5
+ __all__ = ["SkillDefinition", "load_skills"]