crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""ImageTool - attach local images with optional inline descriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.io_worker import run_file_tool
|
|
10
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolContext, ToolResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImageTool(Tool):
|
|
14
|
+
"""Emit local images as separate ImageContent blocks.
|
|
15
|
+
|
|
16
|
+
Codex tools send image bytes through ``emitImage`` instead of putting a
|
|
17
|
+
filesystem path in Markdown. This tool provides the same explicit
|
|
18
|
+
boundary to the model after another tool creates an image file.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "Image"
|
|
22
|
+
description = "View and analyze local images, including PSD composite previews, or attach images for the user."
|
|
23
|
+
is_read_only = True
|
|
24
|
+
is_concurrency_safe = True
|
|
25
|
+
input_schema: dict[str, Any] = {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"path": {
|
|
29
|
+
"anyOf": [
|
|
30
|
+
{"type": "string", "minLength": 1},
|
|
31
|
+
{"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1},
|
|
32
|
+
],
|
|
33
|
+
"description": "One absolute or session-relative image path, or a non-empty list of image paths in display order.",
|
|
34
|
+
},
|
|
35
|
+
"description": {
|
|
36
|
+
"anyOf": [
|
|
37
|
+
{"type": "string"},
|
|
38
|
+
{"type": "array", "items": {"type": "string"}, "minItems": 1},
|
|
39
|
+
],
|
|
40
|
+
"description": "Optional plain-text caption below each image. Use a string for one image, or a list matching the paths in length and order. Use an empty string to omit an individual caption.",
|
|
41
|
+
},
|
|
42
|
+
"mime_type": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Optional expected output image MIME type for all paths. Converted previews use image/png. Usually omit to detect automatically.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": ["path"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
51
|
+
return (
|
|
52
|
+
"View and analyze one or more local images, or attach images for the user. "
|
|
53
|
+
"You receive the actual image content for visual analysis after this tool returns. "
|
|
54
|
+
"Read also supports viewing a single local image. PSD files are converted "
|
|
55
|
+
"to saved composite previews; other non-native formats are converted to PNG. "
|
|
56
|
+
"Large images may be resized; animated/multipage images show the first frame/page. The image "
|
|
57
|
+
"bytes are sent as a separate ImageContent block, so do not put a "
|
|
58
|
+
"file:// or local filesystem path in Markdown. Pass path as a string "
|
|
59
|
+
"or a non-empty list of paths. Optionally pass description as a string "
|
|
60
|
+
"for one image or a list matching the paths in length and order; each "
|
|
61
|
+
"description appears below its image as plain text. Empty descriptions "
|
|
62
|
+
"are allowed. All files are validated before any images are attached. "
|
|
63
|
+
"mime_type, if supplied, must match the decoded image format."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def check_permissions(
|
|
67
|
+
self,
|
|
68
|
+
tool_input: dict[str, Any],
|
|
69
|
+
context: ToolContext,
|
|
70
|
+
) -> PermissionResult:
|
|
71
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
72
|
+
|
|
73
|
+
async def call(
|
|
74
|
+
self,
|
|
75
|
+
tool_input: dict[str, Any],
|
|
76
|
+
context: ToolContext,
|
|
77
|
+
) -> ToolResult:
|
|
78
|
+
result = await run_file_tool(self.name, tool_input, context)
|
|
79
|
+
context.emitted_images.extend(result.images)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
async def _call_local(
|
|
83
|
+
self,
|
|
84
|
+
tool_input: dict[str, Any],
|
|
85
|
+
context: ToolContext,
|
|
86
|
+
) -> ToolResult:
|
|
87
|
+
raw_paths = tool_input.get("path", tool_input.get("file_path", ""))
|
|
88
|
+
if raw_paths == "":
|
|
89
|
+
raw_paths = tool_input.get("file_path", "")
|
|
90
|
+
paths = [raw_paths] if isinstance(raw_paths, str) else raw_paths
|
|
91
|
+
if not isinstance(paths, list) or not paths:
|
|
92
|
+
return ToolResult(result_for_model="Error: path must be a string or a non-empty list of strings", is_error=True)
|
|
93
|
+
if any(not isinstance(path, str) or not path.strip() for path in paths):
|
|
94
|
+
return ToolResult(result_for_model="Error: every path must be a non-empty string", is_error=True)
|
|
95
|
+
|
|
96
|
+
descriptions = tool_input.get("description", [""] * len(paths))
|
|
97
|
+
if isinstance(descriptions, str):
|
|
98
|
+
descriptions = [descriptions]
|
|
99
|
+
if (
|
|
100
|
+
not isinstance(descriptions, list)
|
|
101
|
+
or len(descriptions) != len(paths)
|
|
102
|
+
or any(not isinstance(description, str) for description in descriptions)
|
|
103
|
+
):
|
|
104
|
+
return ToolResult(
|
|
105
|
+
result_for_model="Error: description must be a string for one image or a list of strings matching the paths in length and order",
|
|
106
|
+
is_error=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Stage attachments so a later invalid file cannot leave a partial batch
|
|
110
|
+
# in the invocation sink, which the tool runner merges even on errors.
|
|
111
|
+
staged = replace(context, emitted_images=[])
|
|
112
|
+
details = []
|
|
113
|
+
messages = []
|
|
114
|
+
for raw_path, description in zip(paths, descriptions):
|
|
115
|
+
result = self._attach_one(raw_path.strip(), description, tool_input, staged)
|
|
116
|
+
if result.is_error:
|
|
117
|
+
return result
|
|
118
|
+
details.append(result.data)
|
|
119
|
+
messages.append(result.result_for_model)
|
|
120
|
+
context.emitted_images.extend(staged.emitted_images)
|
|
121
|
+
message = "\n".join(messages)
|
|
122
|
+
return ToolResult(
|
|
123
|
+
data=details[0] if isinstance(raw_paths, str) else {"images": details},
|
|
124
|
+
result_for_model=message,
|
|
125
|
+
result_for_display=message,
|
|
126
|
+
images=staged.emitted_images,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _attach_one(
|
|
130
|
+
self, raw_path: str, description: str, tool_input: dict[str, Any], context: ToolContext,
|
|
131
|
+
) -> ToolResult:
|
|
132
|
+
if not raw_path:
|
|
133
|
+
return ToolResult(result_for_model="Error: path is required", is_error=True)
|
|
134
|
+
|
|
135
|
+
path = Path(raw_path).expanduser()
|
|
136
|
+
if not path.is_absolute():
|
|
137
|
+
path = Path(context.cwd) / path
|
|
138
|
+
try:
|
|
139
|
+
path = path.resolve()
|
|
140
|
+
except OSError:
|
|
141
|
+
# Keep the original path for a useful error if resolution fails.
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
if not path.exists():
|
|
145
|
+
return ToolResult(result_for_model=f"Error: image not found: {path}", is_error=True)
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
return ToolResult(result_for_model=f"Error: not a file: {path}", is_error=True)
|
|
148
|
+
|
|
149
|
+
mime_type = str(
|
|
150
|
+
tool_input.get("mime_type", "")
|
|
151
|
+
or tool_input.get("mimeType", "")
|
|
152
|
+
or ""
|
|
153
|
+
).strip()
|
|
154
|
+
try:
|
|
155
|
+
from crabcode_core.image_io import load_image_preview
|
|
156
|
+
|
|
157
|
+
preview = load_image_preview(path)
|
|
158
|
+
if mime_type and mime_type.lower() != preview.media_type:
|
|
159
|
+
raise ValueError(f"mime_type must match the image content ({preview.media_type})")
|
|
160
|
+
data, mime_type = preview.data, preview.media_type
|
|
161
|
+
context.emit_image(data, mime_type, description=description)
|
|
162
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
163
|
+
return ToolResult(result_for_model=f"Error attaching image {path}: {exc}", is_error=True)
|
|
164
|
+
|
|
165
|
+
message = f"Image attached: {path} ({preview.summary()})"
|
|
166
|
+
if description:
|
|
167
|
+
message += f"\nDescription: {description}"
|
|
168
|
+
return ToolResult(
|
|
169
|
+
data={"path": str(path), "media_type": mime_type, "bytes": len(data), **({"description": description} if description else {})},
|
|
170
|
+
result_for_model=message,
|
|
171
|
+
result_for_display=message,
|
|
172
|
+
images=[context.emitted_images[-1]],
|
|
173
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Generate images with the active Codex provider and return inline artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import mimetypes
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from crabcode_core.types.tool import MAX_INLINE_IMAGE_BYTES, Tool, ToolContext, ToolResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImageGenerateTool(Tool):
|
|
18
|
+
name = "ImageGenerate"
|
|
19
|
+
description = (
|
|
20
|
+
"Generate or edit a raster image using the current Codex auth.json login "
|
|
21
|
+
"or an explicitly enabled compatible Responses provider. Uses the active "
|
|
22
|
+
"provider's configured credentials. Describe the subject, style, composition, "
|
|
23
|
+
"exact text and constraints in prompt. For edits or visual references, pass "
|
|
24
|
+
"reference_image_paths after inspecting those images with Image. State each "
|
|
25
|
+
"reference's role and what must remain unchanged in prompt. Saves unique files "
|
|
26
|
+
"under output/imagegen in the current workspace and attaches images inline. "
|
|
27
|
+
"Use once per asset; generation may take several minutes. Only use when the "
|
|
28
|
+
"user requests image generation or the task needs a generated image."
|
|
29
|
+
)
|
|
30
|
+
is_concurrency_safe = True
|
|
31
|
+
input_schema = {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"prompt": {"type": "string", "minLength": 1},
|
|
35
|
+
"reference_image_paths": {
|
|
36
|
+
"type": "array", "maxItems": 5,
|
|
37
|
+
"items": {"type": "string", "minLength": 1},
|
|
38
|
+
"description": "Optional local images for editing or visual reference, relative to the workspace or absolute.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"required": ["prompt"],
|
|
42
|
+
"additionalProperties": False,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def is_available(self, context: ToolContext) -> bool:
|
|
46
|
+
return self.is_enabled and bool(getattr(context.api_adapter, "supports_image_generation", False))
|
|
47
|
+
|
|
48
|
+
async def call(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
49
|
+
if not self.is_available(context):
|
|
50
|
+
return ToolResult(
|
|
51
|
+
is_error=True,
|
|
52
|
+
result_for_model=(
|
|
53
|
+
"ImageGenerate requires Codex auth.json mode or "
|
|
54
|
+
"image_generation_enabled=true on a compatible Codex model."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
prompt = tool_input.get("prompt")
|
|
58
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
59
|
+
return ToolResult(is_error=True, result_for_model="Error: prompt is required")
|
|
60
|
+
paths = tool_input.get("reference_image_paths", [])
|
|
61
|
+
if not isinstance(paths, list) or len(paths) > 5 or any(not isinstance(p, str) or not p.strip() for p in paths):
|
|
62
|
+
return ToolResult(is_error=True, result_for_model="Error: provide at most five reference image paths")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
references = []
|
|
66
|
+
for raw_path in paths:
|
|
67
|
+
path = Path(raw_path).expanduser()
|
|
68
|
+
if not path.is_absolute():
|
|
69
|
+
path = Path(context.cwd) / path
|
|
70
|
+
media_type = mimetypes.guess_type(path.name)[0]
|
|
71
|
+
if media_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
|
|
72
|
+
raise ValueError(f"Unsupported reference image type: {path}")
|
|
73
|
+
if path.stat().st_size > MAX_INLINE_IMAGE_BYTES:
|
|
74
|
+
raise ValueError("Reference image exceeds the 20MB limit")
|
|
75
|
+
raw = path.read_bytes()
|
|
76
|
+
if not raw or len(raw) > MAX_INLINE_IMAGE_BYTES:
|
|
77
|
+
raise ValueError("Reference image is empty or exceeds the 20MB limit")
|
|
78
|
+
references.append({"media_type": media_type, "data": base64.b64encode(raw).decode("ascii")})
|
|
79
|
+
|
|
80
|
+
images = await context.api_adapter.generate_images(
|
|
81
|
+
prompt.strip(), model=context.model or context.api_adapter.config.model,
|
|
82
|
+
reference_images=references,
|
|
83
|
+
)
|
|
84
|
+
# Validate all results before saving or publishing any image.
|
|
85
|
+
decoded = []
|
|
86
|
+
for image in images:
|
|
87
|
+
encoded = image["data"]
|
|
88
|
+
if len(encoded) > 4 * ((MAX_INLINE_IMAGE_BYTES + 2) // 3):
|
|
89
|
+
raise ValueError("Generated image exceeds the 20MB limit")
|
|
90
|
+
raw = base64.b64decode(encoded, validate=True)
|
|
91
|
+
if not raw or len(raw) > MAX_INLINE_IMAGE_BYTES:
|
|
92
|
+
raise ValueError("Generated image is empty or exceeds the 20MB limit")
|
|
93
|
+
suffix = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(image["media_type"])
|
|
94
|
+
if not suffix:
|
|
95
|
+
raise ValueError("Unsupported generated image MIME type")
|
|
96
|
+
decoded.append((raw, image["media_type"], suffix))
|
|
97
|
+
if not decoded:
|
|
98
|
+
raise ValueError("The provider returned no generated image")
|
|
99
|
+
|
|
100
|
+
output_dir = Path(context.cwd).resolve() / "output" / "imagegen"
|
|
101
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
saved = []
|
|
103
|
+
for raw, media_type, suffix in decoded:
|
|
104
|
+
path = output_dir / f"image-{uuid4().hex}{suffix}"
|
|
105
|
+
with path.open("xb") as file:
|
|
106
|
+
file.write(raw)
|
|
107
|
+
saved.append(str(path))
|
|
108
|
+
context.emit_image(raw, media_type)
|
|
109
|
+
message = "Generated image(s):\n" + "\n".join(saved)
|
|
110
|
+
return ToolResult(
|
|
111
|
+
data={"paths": saved}, result_for_model=message,
|
|
112
|
+
result_for_display=message, images=context.emitted_images[-len(saved):],
|
|
113
|
+
)
|
|
114
|
+
except (OSError, ValueError, binascii.Error, RuntimeError, httpx.HTTPError) as exc:
|
|
115
|
+
return ToolResult(is_error=True, result_for_model=f"Image generation failed: {exc}")
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""LintTool — read linter errors and diagnostics for specified files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from crabcode_core.logging_utils import get_logger
|
|
11
|
+
from crabcode_core.subprocess_utils import (
|
|
12
|
+
decode_subprocess_output,
|
|
13
|
+
managed_process_command,
|
|
14
|
+
resolve_executable_command,
|
|
15
|
+
subprocess_group_options,
|
|
16
|
+
terminate_process_tree,
|
|
17
|
+
)
|
|
18
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_LINTER_CONFIGS: list[dict[str, Any]] = [
|
|
23
|
+
# ── Python ──
|
|
24
|
+
{
|
|
25
|
+
"name": "ruff",
|
|
26
|
+
"extensions": {".py", ".pyi"},
|
|
27
|
+
"cmd": ["ruff", "check", "--output-format=concise"],
|
|
28
|
+
"detect_files": {"ruff.toml", ".ruff.toml"},
|
|
29
|
+
"detect_sections": {"ruff"},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "pylint",
|
|
33
|
+
"extensions": {".py"},
|
|
34
|
+
"cmd": ["pylint", "--output-format=text", "--score=no"],
|
|
35
|
+
"detect_files": {".pylintrc", "pylintrc"},
|
|
36
|
+
"detect_sections": {"pylint"},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "mypy",
|
|
40
|
+
"extensions": {".py", ".pyi"},
|
|
41
|
+
"cmd": ["mypy", "--no-error-summary", "--no-pretty", "--show-column-numbers"],
|
|
42
|
+
"detect_files": {"mypy.ini", ".mypy.ini"},
|
|
43
|
+
"detect_sections": {"mypy"},
|
|
44
|
+
},
|
|
45
|
+
# ── JavaScript / TypeScript ──
|
|
46
|
+
{
|
|
47
|
+
"name": "eslint",
|
|
48
|
+
"extensions": {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"},
|
|
49
|
+
"cmd": ["eslint", "--format=unix", "--no-color"],
|
|
50
|
+
"detect_files": {
|
|
51
|
+
".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json",
|
|
52
|
+
".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js",
|
|
53
|
+
"eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts",
|
|
54
|
+
},
|
|
55
|
+
"detect_sections": set(),
|
|
56
|
+
},
|
|
57
|
+
# ── Go ──
|
|
58
|
+
{
|
|
59
|
+
"name": "golangci-lint",
|
|
60
|
+
"extensions": {".go"},
|
|
61
|
+
"cmd": ["golangci-lint", "run", "--out-format=line-number"],
|
|
62
|
+
"detect_files": {".golangci.yml", ".golangci.yaml", ".golangci.toml"},
|
|
63
|
+
"detect_sections": set(),
|
|
64
|
+
},
|
|
65
|
+
# ── Rust ──
|
|
66
|
+
{
|
|
67
|
+
"name": "clippy",
|
|
68
|
+
"extensions": {".rs"},
|
|
69
|
+
"cmd": ["cargo", "clippy", "--quiet", "--message-format=short"],
|
|
70
|
+
"detect_files": {"Cargo.toml"},
|
|
71
|
+
"detect_sections": set(),
|
|
72
|
+
"project_level": True,
|
|
73
|
+
"use_stderr": True,
|
|
74
|
+
},
|
|
75
|
+
# ── C / C++ ──
|
|
76
|
+
{
|
|
77
|
+
"name": "clang-tidy",
|
|
78
|
+
"extensions": {".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx"},
|
|
79
|
+
"cmd": ["clang-tidy"],
|
|
80
|
+
"detect_files": {".clang-tidy", "compile_commands.json"},
|
|
81
|
+
"detect_sections": set(),
|
|
82
|
+
"suffix_args": ["--"],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "cppcheck",
|
|
86
|
+
"extensions": {".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx"},
|
|
87
|
+
"cmd": ["cppcheck", "--enable=warning,style,performance", "--template=gcc", "--quiet"],
|
|
88
|
+
"detect_files": set(),
|
|
89
|
+
"detect_sections": set(),
|
|
90
|
+
},
|
|
91
|
+
# ── Java ──
|
|
92
|
+
{
|
|
93
|
+
"name": "checkstyle",
|
|
94
|
+
"extensions": {".java"},
|
|
95
|
+
"cmd": ["checkstyle"],
|
|
96
|
+
"detect_files": {"checkstyle.xml", ".checkstyle", "checkstyle-config.xml"},
|
|
97
|
+
"detect_sections": set(),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "pmd",
|
|
101
|
+
"extensions": {".java"},
|
|
102
|
+
"cmd": ["pmd", "check", "-f", "text", "--no-progress", "-R",
|
|
103
|
+
"rulesets/java/quickstart.xml"],
|
|
104
|
+
"detect_files": {"ruleset.xml", "pmd-ruleset.xml"},
|
|
105
|
+
"detect_sections": set(),
|
|
106
|
+
"target_flag": "-d",
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _has_pyproject_section(cwd: str, section_names: set[str]) -> bool:
|
|
112
|
+
"""Check if pyproject.toml has a [tool.<name>] section."""
|
|
113
|
+
if not section_names:
|
|
114
|
+
return False
|
|
115
|
+
pyproject = Path(cwd) / "pyproject.toml"
|
|
116
|
+
if not pyproject.exists():
|
|
117
|
+
return False
|
|
118
|
+
try:
|
|
119
|
+
text = pyproject.read_text(encoding="utf-8", errors="replace")
|
|
120
|
+
return any(f"[tool.{s}]" in text for s in section_names)
|
|
121
|
+
except Exception:
|
|
122
|
+
logger.debug("Failed to read %s while detecting lint config", pyproject, exc_info=True)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _has_config_file(cwd: str, filenames: set[str]) -> bool:
|
|
127
|
+
"""Check if any of the given config files exist in cwd."""
|
|
128
|
+
for name in filenames:
|
|
129
|
+
if (Path(cwd) / name).exists():
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _detect_linters(
|
|
135
|
+
paths: list[Path], cwd: str
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
"""Pick applicable linters based on file extensions and project config.
|
|
138
|
+
|
|
139
|
+
When no paths are given, scans for project config files to decide which
|
|
140
|
+
linters apply instead of guessing by extension.
|
|
141
|
+
"""
|
|
142
|
+
extensions = {p.suffix for p in paths if p.suffix}
|
|
143
|
+
no_paths = not paths
|
|
144
|
+
|
|
145
|
+
matched: list[dict[str, Any]] = []
|
|
146
|
+
seen_names: set[str] = set()
|
|
147
|
+
for cfg in _LINTER_CONFIGS:
|
|
148
|
+
if cfg["name"] in seen_names:
|
|
149
|
+
continue
|
|
150
|
+
if not shutil.which(cfg["cmd"][0]):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
has_config = (
|
|
154
|
+
_has_config_file(cwd, cfg["detect_files"])
|
|
155
|
+
or _has_pyproject_section(cwd, cfg["detect_sections"])
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if no_paths:
|
|
159
|
+
if not has_config:
|
|
160
|
+
continue
|
|
161
|
+
else:
|
|
162
|
+
if not extensions & cfg["extensions"]:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
matched.append({**cfg, "has_config": has_config})
|
|
166
|
+
seen_names.add(cfg["name"])
|
|
167
|
+
|
|
168
|
+
if not matched:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
configured = [m for m in matched if m["has_config"]]
|
|
172
|
+
if configured:
|
|
173
|
+
return configured
|
|
174
|
+
return matched[:1]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _run_linter(
|
|
178
|
+
cfg: dict[str, Any],
|
|
179
|
+
targets: list[str],
|
|
180
|
+
cwd: str,
|
|
181
|
+
timeout: int = 60,
|
|
182
|
+
) -> tuple[str, str, int]:
|
|
183
|
+
"""Run a linter command and return (output, stderr, exit_code).
|
|
184
|
+
|
|
185
|
+
Respects per-linter config flags:
|
|
186
|
+
project_level – don't append file targets
|
|
187
|
+
use_stderr – read diagnostics from stderr instead of stdout
|
|
188
|
+
suffix_args – extra args appended after targets (e.g. ["--"] for clang-tidy)
|
|
189
|
+
target_flag – flag before each target (e.g. "-d" for pmd)
|
|
190
|
+
"""
|
|
191
|
+
cmd = cfg["cmd"]
|
|
192
|
+
is_project_level = cfg.get("project_level", False)
|
|
193
|
+
target_flag = cfg.get("target_flag")
|
|
194
|
+
suffix_args = cfg.get("suffix_args", [])
|
|
195
|
+
|
|
196
|
+
full_cmd = list(cmd)
|
|
197
|
+
if not is_project_level:
|
|
198
|
+
if target_flag:
|
|
199
|
+
full_cmd.extend([target_flag, ",".join(targets)])
|
|
200
|
+
else:
|
|
201
|
+
full_cmd.extend(targets)
|
|
202
|
+
full_cmd.extend(suffix_args)
|
|
203
|
+
|
|
204
|
+
proc: asyncio.subprocess.Process | None = None
|
|
205
|
+
try:
|
|
206
|
+
full_cmd = resolve_executable_command(full_cmd, cwd=cwd)
|
|
207
|
+
proc = await asyncio.create_subprocess_exec(
|
|
208
|
+
*managed_process_command(full_cmd),
|
|
209
|
+
stdout=asyncio.subprocess.PIPE,
|
|
210
|
+
stderr=asyncio.subprocess.PIPE,
|
|
211
|
+
cwd=cwd,
|
|
212
|
+
**subprocess_group_options(),
|
|
213
|
+
)
|
|
214
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
215
|
+
proc.communicate(), timeout=timeout
|
|
216
|
+
)
|
|
217
|
+
stdout = decode_subprocess_output(stdout_bytes)
|
|
218
|
+
stderr = decode_subprocess_output(stderr_bytes)
|
|
219
|
+
|
|
220
|
+
if cfg.get("use_stderr"):
|
|
221
|
+
return stderr, stdout, proc.returncode or 0
|
|
222
|
+
return stdout, stderr, proc.returncode or 0
|
|
223
|
+
except asyncio.TimeoutError:
|
|
224
|
+
if proc is not None and proc.returncode is None:
|
|
225
|
+
await terminate_process_tree(proc)
|
|
226
|
+
return "", f"Linter timed out after {timeout}s", -1
|
|
227
|
+
except asyncio.CancelledError:
|
|
228
|
+
if proc is not None and proc.returncode is None:
|
|
229
|
+
await terminate_process_tree(proc)
|
|
230
|
+
raise
|
|
231
|
+
except FileNotFoundError:
|
|
232
|
+
return "", f"Linter command not found: {cmd[0]}", -1
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
return "", str(exc), -1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class LintTool(Tool):
|
|
238
|
+
name = "Lint"
|
|
239
|
+
description = "Read linter errors and diagnostics for specified files."
|
|
240
|
+
is_read_only = True
|
|
241
|
+
is_concurrency_safe = True
|
|
242
|
+
input_schema = {
|
|
243
|
+
"type": "object",
|
|
244
|
+
"properties": {
|
|
245
|
+
"paths": {
|
|
246
|
+
"type": "array",
|
|
247
|
+
"items": {"type": "string"},
|
|
248
|
+
"description": (
|
|
249
|
+
"File or directory paths to check. "
|
|
250
|
+
"If omitted, checks the current working directory."
|
|
251
|
+
),
|
|
252
|
+
},
|
|
253
|
+
"linter": {
|
|
254
|
+
"type": "string",
|
|
255
|
+
"description": (
|
|
256
|
+
"Force a specific linter (e.g., 'ruff', 'eslint', 'mypy'). "
|
|
257
|
+
"If omitted, auto-detects based on file types and project config."
|
|
258
|
+
),
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
264
|
+
return (
|
|
265
|
+
"Read linter errors and diagnostics for specified files or directories. "
|
|
266
|
+
"Auto-detects the appropriate linter based on file types and project "
|
|
267
|
+
"configuration.\n\n"
|
|
268
|
+
"Supported linters:\n"
|
|
269
|
+
"- Python: ruff, pylint, mypy\n"
|
|
270
|
+
"- JavaScript/TypeScript: eslint\n"
|
|
271
|
+
"- Go: golangci-lint\n"
|
|
272
|
+
"- Rust: clippy (via cargo)\n"
|
|
273
|
+
"- C/C++: clang-tidy, cppcheck\n"
|
|
274
|
+
"- Java: checkstyle, pmd\n\n"
|
|
275
|
+
"Use this tool after making edits to verify you haven't introduced "
|
|
276
|
+
"errors. You can provide specific file paths or omit them to check "
|
|
277
|
+
"the whole project.\n\n"
|
|
278
|
+
"Guidelines:\n"
|
|
279
|
+
"- Only use on files you've edited or are about to edit.\n"
|
|
280
|
+
"- Results may include pre-existing errors — focus on errors in "
|
|
281
|
+
"code you changed.\n"
|
|
282
|
+
"- If no linter is installed or configured, the tool will report "
|
|
283
|
+
"that clearly.\n"
|
|
284
|
+
"- For Rust projects, clippy runs at the project level (ignores "
|
|
285
|
+
"individual file paths)."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def call(
|
|
289
|
+
self,
|
|
290
|
+
tool_input: dict[str, Any],
|
|
291
|
+
context: ToolContext,
|
|
292
|
+
) -> ToolResult:
|
|
293
|
+
raw_paths = tool_input.get("paths") or []
|
|
294
|
+
forced_linter = tool_input.get("linter")
|
|
295
|
+
|
|
296
|
+
cwd = context.cwd
|
|
297
|
+
|
|
298
|
+
resolved: list[Path] = []
|
|
299
|
+
for p in raw_paths:
|
|
300
|
+
path = Path(p)
|
|
301
|
+
if not path.is_absolute():
|
|
302
|
+
path = Path(cwd) / path
|
|
303
|
+
resolved.append(path)
|
|
304
|
+
|
|
305
|
+
missing = [str(p) for p in resolved if not p.exists()]
|
|
306
|
+
if missing:
|
|
307
|
+
return ToolResult(
|
|
308
|
+
result_for_model=f"Error: paths not found: {', '.join(missing)}",
|
|
309
|
+
is_error=True,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if forced_linter:
|
|
313
|
+
cfg = next(
|
|
314
|
+
(c for c in _LINTER_CONFIGS if c["name"] == forced_linter), None
|
|
315
|
+
)
|
|
316
|
+
if cfg is None:
|
|
317
|
+
known = ", ".join(c["name"] for c in _LINTER_CONFIGS)
|
|
318
|
+
return ToolResult(
|
|
319
|
+
result_for_model=(
|
|
320
|
+
f"Unknown linter '{forced_linter}'. "
|
|
321
|
+
f"Supported: {known}"
|
|
322
|
+
),
|
|
323
|
+
is_error=True,
|
|
324
|
+
)
|
|
325
|
+
if not shutil.which(cfg["cmd"][0]):
|
|
326
|
+
return ToolResult(
|
|
327
|
+
result_for_model=(
|
|
328
|
+
f"Linter '{forced_linter}' is not installed. "
|
|
329
|
+
f"Install it and try again."
|
|
330
|
+
),
|
|
331
|
+
is_error=True,
|
|
332
|
+
)
|
|
333
|
+
linters = [cfg]
|
|
334
|
+
else:
|
|
335
|
+
linters = _detect_linters(resolved, cwd)
|
|
336
|
+
if not linters:
|
|
337
|
+
return ToolResult(
|
|
338
|
+
result_for_model=(
|
|
339
|
+
"No suitable linter found. Either no linter is installed "
|
|
340
|
+
"for the given file types, or no matching files were provided.\n"
|
|
341
|
+
"Supported: ruff, pylint, mypy, eslint, golangci-lint, clippy, "
|
|
342
|
+
"clang-tidy, cppcheck, checkstyle, pmd."
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
targets = [str(p) for p in resolved] if resolved else ["."]
|
|
347
|
+
|
|
348
|
+
all_output: list[str] = []
|
|
349
|
+
total_issues = 0
|
|
350
|
+
|
|
351
|
+
for linter_cfg in linters:
|
|
352
|
+
linter_name = linter_cfg["name"]
|
|
353
|
+
stdout, stderr, exit_code = await _run_linter(
|
|
354
|
+
linter_cfg, targets, cwd
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if exit_code == -1:
|
|
358
|
+
all_output.append(f"[{linter_name}] Error: {stderr}")
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
output = stdout.strip()
|
|
362
|
+
if not output and exit_code == 0:
|
|
363
|
+
all_output.append(f"[{linter_name}] No issues found.")
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
lines = output.split("\n") if output else []
|
|
367
|
+
issue_count = len([
|
|
368
|
+
l for l in lines
|
|
369
|
+
if l.strip() and not l.startswith((" ", "\t"))
|
|
370
|
+
])
|
|
371
|
+
total_issues += issue_count
|
|
372
|
+
|
|
373
|
+
max_chars = 50_000
|
|
374
|
+
if len(output) > max_chars:
|
|
375
|
+
output = output[:max_chars] + "\n... (truncated)"
|
|
376
|
+
|
|
377
|
+
header = f"[{linter_name}] {issue_count} issue(s):"
|
|
378
|
+
all_output.append(f"{header}\n{output}")
|
|
379
|
+
|
|
380
|
+
if stderr.strip() and exit_code != 0:
|
|
381
|
+
err_preview = stderr.strip()[:2000]
|
|
382
|
+
all_output.append(f"[{linter_name}] stderr:\n{err_preview}")
|
|
383
|
+
|
|
384
|
+
result_text = "\n\n".join(all_output)
|
|
385
|
+
return ToolResult(
|
|
386
|
+
data={"total_issues": total_issues},
|
|
387
|
+
result_for_model=result_text,
|
|
388
|
+
)
|