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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Skill loading — scans .crabcode/skills/ and .claude/skills/ directories.
|
|
2
|
+
|
|
3
|
+
Search order (later entries override earlier ones with the same name):
|
|
4
|
+
1. ~/.claude/skills/<name>/SKILL.md (user global, Claude Code compat)
|
|
5
|
+
2. ~/.crabcode/skills/<name>/SKILL.md (user global, crabcode native)
|
|
6
|
+
3. .claude/skills/<name>/SKILL.md (project, walking up to home)
|
|
7
|
+
4. .crabcode/skills/<name>/SKILL.md (project, walking up to home, highest priority)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SkillDefinition:
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
content: str
|
|
22
|
+
source_path: str
|
|
23
|
+
when_to_use: str | None = None
|
|
24
|
+
paths: list[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
28
|
+
"""Split YAML frontmatter from Markdown body.
|
|
29
|
+
|
|
30
|
+
Returns (frontmatter_dict, body). If no frontmatter block is found,
|
|
31
|
+
returns ({}, original text).
|
|
32
|
+
"""
|
|
33
|
+
match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.DOTALL)
|
|
34
|
+
if not match:
|
|
35
|
+
return {}, text
|
|
36
|
+
|
|
37
|
+
raw_yaml = match.group(1)
|
|
38
|
+
body = text[match.end():]
|
|
39
|
+
|
|
40
|
+
data: dict[str, str] = {}
|
|
41
|
+
for line in raw_yaml.splitlines():
|
|
42
|
+
kv = re.match(r'^([\w][\w_-]*):\s*"?(.*?)"?\s*$', line)
|
|
43
|
+
if kv:
|
|
44
|
+
data[kv.group(1)] = kv.group(2)
|
|
45
|
+
|
|
46
|
+
return data, body.lstrip("\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_skill_from_file(skill_file: Path) -> SkillDefinition | None:
|
|
50
|
+
"""Parse a single SKILL.md file and return a SkillDefinition, or None on error."""
|
|
51
|
+
try:
|
|
52
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
53
|
+
except OSError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
fm, body = _parse_frontmatter(text)
|
|
57
|
+
if not body.strip() and not fm:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Derive name: prefer frontmatter, fall back to parent dir name
|
|
61
|
+
name = fm.get("name") or skill_file.parent.name
|
|
62
|
+
description = fm.get("description", "")
|
|
63
|
+
when_to_use = fm.get("when_to_use") or None
|
|
64
|
+
|
|
65
|
+
raw_paths = fm.get("paths", "")
|
|
66
|
+
paths = [p.strip() for p in raw_paths.split(",") if p.strip()] if raw_paths else []
|
|
67
|
+
|
|
68
|
+
return SkillDefinition(
|
|
69
|
+
name=name,
|
|
70
|
+
description=description,
|
|
71
|
+
content=body.strip(),
|
|
72
|
+
source_path=str(skill_file),
|
|
73
|
+
when_to_use=when_to_use,
|
|
74
|
+
paths=paths,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _scan_skills_dir(base: Path) -> dict[str, SkillDefinition]:
|
|
79
|
+
"""Scan a skills directory for <skill-name>/SKILL.md entries."""
|
|
80
|
+
skills: dict[str, SkillDefinition] = {}
|
|
81
|
+
if not base.is_dir():
|
|
82
|
+
return skills
|
|
83
|
+
|
|
84
|
+
for entry in sorted(base.iterdir()):
|
|
85
|
+
if not (entry.is_dir() or entry.is_symlink()):
|
|
86
|
+
continue
|
|
87
|
+
skill_file = entry / "SKILL.md"
|
|
88
|
+
if not skill_file.is_file():
|
|
89
|
+
continue
|
|
90
|
+
skill = _load_skill_from_file(skill_file)
|
|
91
|
+
if skill:
|
|
92
|
+
skills[skill.name] = skill
|
|
93
|
+
|
|
94
|
+
return skills
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dirs_up_to_home(cwd: str, config_dir: str) -> list[Path]:
|
|
98
|
+
"""Return skill dirs from home down to cwd for a given config directory name.
|
|
99
|
+
|
|
100
|
+
Walking home → cwd means closer (more specific) directories override
|
|
101
|
+
farther ones when we merge.
|
|
102
|
+
"""
|
|
103
|
+
home = Path.home()
|
|
104
|
+
current = Path(cwd).resolve()
|
|
105
|
+
dirs: list[Path] = []
|
|
106
|
+
|
|
107
|
+
path = current
|
|
108
|
+
while True:
|
|
109
|
+
# Skip the home directory itself — it's covered by the global scan
|
|
110
|
+
if path != home:
|
|
111
|
+
dirs.append(path / config_dir / "skills")
|
|
112
|
+
if path == home or path.parent == path:
|
|
113
|
+
break
|
|
114
|
+
path = path.parent
|
|
115
|
+
|
|
116
|
+
# Reverse so home-adjacent dirs come first (lower priority), cwd last (highest)
|
|
117
|
+
dirs.reverse()
|
|
118
|
+
return dirs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_skills(cwd: str) -> list[SkillDefinition]:
|
|
122
|
+
"""Load all skills visible from *cwd*, merged by priority.
|
|
123
|
+
|
|
124
|
+
Skills with the same name loaded later override earlier ones.
|
|
125
|
+
"""
|
|
126
|
+
merged: dict[str, SkillDefinition] = {}
|
|
127
|
+
home = Path.home()
|
|
128
|
+
|
|
129
|
+
# 1. ~/.claude/skills/ — lowest priority (Claude Code global compat)
|
|
130
|
+
for name, skill in _scan_skills_dir(home / ".claude" / "skills").items():
|
|
131
|
+
merged[name] = skill
|
|
132
|
+
|
|
133
|
+
# 2. ~/.crabcode/skills/ — overrides claude global
|
|
134
|
+
for name, skill in _scan_skills_dir(home / ".crabcode" / "skills").items():
|
|
135
|
+
merged[name] = skill
|
|
136
|
+
|
|
137
|
+
# 3. Project-level .claude/skills/ dirs (home-adjacent → cwd)
|
|
138
|
+
for skills_dir in _dirs_up_to_home(cwd, ".claude"):
|
|
139
|
+
for name, skill in _scan_skills_dir(skills_dir).items():
|
|
140
|
+
merged[name] = skill
|
|
141
|
+
|
|
142
|
+
# 4. Project-level .crabcode/skills/ dirs (highest priority)
|
|
143
|
+
for skills_dir in _dirs_up_to_home(cwd, ".crabcode"):
|
|
144
|
+
for name, skill in _scan_skills_dir(skills_dir).items():
|
|
145
|
+
merged[name] = skill
|
|
146
|
+
|
|
147
|
+
return list(merged.values())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Tool system - built-in tools and tool registry."""
|
|
2
|
+
|
|
3
|
+
from crabcode_core.types.tool import Tool, ToolResult, ToolContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_default_tools() -> list[Tool]:
|
|
7
|
+
"""Return the default set of built-in tools."""
|
|
8
|
+
from crabcode_core.tools.bash import BashTool
|
|
9
|
+
from crabcode_core.tools.file_read import FileReadTool
|
|
10
|
+
from crabcode_core.tools.file_edit import FileEditTool
|
|
11
|
+
from crabcode_core.tools.file_write import FileWriteTool
|
|
12
|
+
from crabcode_core.tools.grep import GrepTool
|
|
13
|
+
from crabcode_core.tools.glob import GlobTool
|
|
14
|
+
from crabcode_core.tools.lint import LintTool
|
|
15
|
+
from crabcode_core.tools.memory import MemoryTool
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
BashTool(),
|
|
19
|
+
FileReadTool(),
|
|
20
|
+
FileEditTool(),
|
|
21
|
+
FileWriteTool(),
|
|
22
|
+
GrepTool(),
|
|
23
|
+
GlobTool(),
|
|
24
|
+
LintTool(),
|
|
25
|
+
MemoryTool(),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["Tool", "ToolResult", "ToolContext", "get_default_tools"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""AgentTool — spawn sub-agents for parallel/isolated work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentTool(Tool):
|
|
12
|
+
name = "Agent"
|
|
13
|
+
description = "Spawn a sub-agent for parallel or isolated tasks."
|
|
14
|
+
is_read_only = False
|
|
15
|
+
is_concurrency_safe = True
|
|
16
|
+
input_schema = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"prompt": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "The task for the sub-agent to perform.",
|
|
22
|
+
},
|
|
23
|
+
"subagent_type": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Type of agent to spawn (e.g., 'explore', 'generalPurpose').",
|
|
26
|
+
"enum": ["explore", "generalPurpose"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"required": ["prompt"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_adapter: Any = None,
|
|
35
|
+
tools: list[Tool] | None = None,
|
|
36
|
+
prompt_profile: Any = None,
|
|
37
|
+
max_turns: int = 10,
|
|
38
|
+
timeout: int = 300,
|
|
39
|
+
max_output_chars: int = 12_000,
|
|
40
|
+
max_display_lines: int = 120,
|
|
41
|
+
):
|
|
42
|
+
self._api_adapter = api_adapter
|
|
43
|
+
self._sub_tools = tools
|
|
44
|
+
self._prompt_profile = prompt_profile
|
|
45
|
+
self._max_turns = max_turns
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._max_output_chars = max_output_chars
|
|
48
|
+
self._max_display_lines = max_display_lines
|
|
49
|
+
|
|
50
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
51
|
+
return (
|
|
52
|
+
"Launch a sub-agent to handle complex, multi-step tasks "
|
|
53
|
+
"autonomously. Subagents are valuable for parallelizing "
|
|
54
|
+
"independent queries or for protecting the main context "
|
|
55
|
+
"window from excessive results.\n\n"
|
|
56
|
+
"Available types:\n"
|
|
57
|
+
"- 'explore': Fast agent for codebase exploration — finding "
|
|
58
|
+
"files, searching code, answering questions about the codebase.\n"
|
|
59
|
+
"- 'generalPurpose': For complex multi-step tasks that need "
|
|
60
|
+
"searching, reading, and executing commands.\n\n"
|
|
61
|
+
"When to use:\n"
|
|
62
|
+
"- Broadly exploring the codebase for context on a large task\n"
|
|
63
|
+
"- Parallelizing independent research queries\n"
|
|
64
|
+
"- Tasks that may produce large outputs\n\n"
|
|
65
|
+
"When NOT to use:\n"
|
|
66
|
+
"- Simple, single-step tasks — just call the tools directly\n"
|
|
67
|
+
"- Searching for a specific file or class — use Glob or Grep\n\n"
|
|
68
|
+
"IMPORTANT: Avoid duplicating work that subagents are already "
|
|
69
|
+
"doing. If you delegate research to a subagent, do not also "
|
|
70
|
+
"perform the same searches yourself."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def call(
|
|
74
|
+
self,
|
|
75
|
+
tool_input: dict[str, Any],
|
|
76
|
+
context: ToolContext,
|
|
77
|
+
) -> ToolResult:
|
|
78
|
+
prompt = tool_input.get("prompt", "")
|
|
79
|
+
|
|
80
|
+
if not prompt:
|
|
81
|
+
return ToolResult(
|
|
82
|
+
result_for_model="Error: prompt is required",
|
|
83
|
+
is_error=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if not self._api_adapter:
|
|
87
|
+
return ToolResult(
|
|
88
|
+
result_for_model="Error: AgentTool requires an API adapter",
|
|
89
|
+
is_error=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
from crabcode_core.prompts.profile import resolve_agent_prompt
|
|
93
|
+
from crabcode_core.query.loop import QueryParams, query_loop
|
|
94
|
+
from crabcode_core.types.message import create_user_message
|
|
95
|
+
|
|
96
|
+
sub_messages = [create_user_message(content=prompt)]
|
|
97
|
+
|
|
98
|
+
sub_context = ToolContext(
|
|
99
|
+
cwd=context.cwd,
|
|
100
|
+
messages=[],
|
|
101
|
+
session_id=context.session_id,
|
|
102
|
+
env=context.env,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
tools = self._sub_tools or []
|
|
106
|
+
agent_prompt = resolve_agent_prompt(self._prompt_profile)
|
|
107
|
+
|
|
108
|
+
params = QueryParams(
|
|
109
|
+
messages=sub_messages,
|
|
110
|
+
system_prompt=[agent_prompt],
|
|
111
|
+
user_context={},
|
|
112
|
+
system_context={},
|
|
113
|
+
tools=tools,
|
|
114
|
+
tool_context=sub_context,
|
|
115
|
+
api_adapter=self._api_adapter,
|
|
116
|
+
max_turns=self._max_turns,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
result_parts: list[str] = []
|
|
120
|
+
|
|
121
|
+
async def _collect_results():
|
|
122
|
+
async for event in query_loop(params):
|
|
123
|
+
from crabcode_core.types.event import (
|
|
124
|
+
ErrorEvent,
|
|
125
|
+
StreamTextEvent,
|
|
126
|
+
ToolResultEvent,
|
|
127
|
+
)
|
|
128
|
+
if isinstance(event, StreamTextEvent):
|
|
129
|
+
result_parts.append(event.text)
|
|
130
|
+
elif isinstance(event, ToolResultEvent):
|
|
131
|
+
body = (event.result or "").strip()
|
|
132
|
+
if len(body) > self._max_output_chars:
|
|
133
|
+
body = body[:self._max_output_chars] + "\n… (truncated)"
|
|
134
|
+
result_parts.append(
|
|
135
|
+
f"\n\n[Tool {event.tool_name} →]\n{body or '(empty)'}\n"
|
|
136
|
+
)
|
|
137
|
+
elif isinstance(event, ErrorEvent):
|
|
138
|
+
result_parts.append(f"\n[Error: {event.message}]")
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
await asyncio.wait_for(_collect_results(), timeout=self._timeout)
|
|
142
|
+
except asyncio.TimeoutError:
|
|
143
|
+
result_parts.append(f"\n[Sub-agent timed out after {self._timeout}s]")
|
|
144
|
+
|
|
145
|
+
result = "".join(result_parts)
|
|
146
|
+
if not result:
|
|
147
|
+
result = "Sub-agent completed but produced no text output."
|
|
148
|
+
|
|
149
|
+
# Build a display-friendly version: keep more content but cap by line count
|
|
150
|
+
display = result
|
|
151
|
+
lines = display.split("\n")
|
|
152
|
+
if len(lines) > self._max_display_lines:
|
|
153
|
+
kept = "\n".join(lines[:self._max_display_lines])
|
|
154
|
+
display = kept + f"\n… ({len(lines) - self._max_display_lines} more lines truncated)"
|
|
155
|
+
|
|
156
|
+
return ToolResult(
|
|
157
|
+
data={"agent_output": result},
|
|
158
|
+
result_for_model=result,
|
|
159
|
+
result_for_display=display,
|
|
160
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""BashTool — execute shell commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BashTool(Tool):
|
|
13
|
+
name = "Bash"
|
|
14
|
+
description = "Execute a bash command in the shell."
|
|
15
|
+
is_read_only = False
|
|
16
|
+
is_concurrency_safe = False
|
|
17
|
+
input_schema = {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"command": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The bash command to execute.",
|
|
23
|
+
},
|
|
24
|
+
"timeout": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"description": "Timeout in seconds (default: 120).",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
"required": ["command"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
33
|
+
return (
|
|
34
|
+
"Execute a bash command in the shell. Use for system commands, "
|
|
35
|
+
"running scripts, git operations, and other terminal tasks. "
|
|
36
|
+
"Prefer dedicated tools (Read, Edit, Write, Glob, Grep) over "
|
|
37
|
+
"bash when they can accomplish the task.\n\n"
|
|
38
|
+
"Commands run in the user's shell with their environment. "
|
|
39
|
+
"The command is executed in the working directory of the "
|
|
40
|
+
"current session. The shell is stateful across calls — "
|
|
41
|
+
"environment variables and directory changes persist.\n\n"
|
|
42
|
+
"Guidelines:\n"
|
|
43
|
+
"- Always quote file paths that contain spaces with double "
|
|
44
|
+
'quotes: cd "/path/with spaces" (correct) vs cd /path/with '
|
|
45
|
+
"spaces (incorrect, will fail).\n"
|
|
46
|
+
"- For long-running processes (dev servers, watchers), they "
|
|
47
|
+
"will be killed when the timeout expires. Warn the user if "
|
|
48
|
+
"a command is expected to run indefinitely.\n"
|
|
49
|
+
"- When issuing multiple independent commands, call Bash "
|
|
50
|
+
"multiple times in parallel rather than chaining with &&.\n"
|
|
51
|
+
"- If commands depend on each other and must run sequentially, "
|
|
52
|
+
"chain them with && in a single call.\n"
|
|
53
|
+
"- Do NOT use interactive commands (e.g., git rebase -i, "
|
|
54
|
+
"vim, nano) — they require user input that is not supported.\n"
|
|
55
|
+
"- If a command fails, read the error output carefully before "
|
|
56
|
+
"retrying. Do not blindly retry the same command."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def call(
|
|
60
|
+
self,
|
|
61
|
+
tool_input: dict[str, Any],
|
|
62
|
+
context: ToolContext,
|
|
63
|
+
) -> ToolResult:
|
|
64
|
+
command = tool_input.get("command", "")
|
|
65
|
+
timeout = tool_input.get("timeout", 120)
|
|
66
|
+
|
|
67
|
+
if not command.strip():
|
|
68
|
+
return ToolResult(
|
|
69
|
+
result_for_model="Error: empty command",
|
|
70
|
+
is_error=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
env = {**os.environ, **context.env}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
proc = await asyncio.create_subprocess_shell(
|
|
77
|
+
command,
|
|
78
|
+
stdout=asyncio.subprocess.PIPE,
|
|
79
|
+
stderr=asyncio.subprocess.PIPE,
|
|
80
|
+
cwd=context.cwd,
|
|
81
|
+
env=env,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
86
|
+
proc.communicate(), timeout=timeout
|
|
87
|
+
)
|
|
88
|
+
except asyncio.TimeoutError:
|
|
89
|
+
proc.kill()
|
|
90
|
+
await proc.wait()
|
|
91
|
+
return ToolResult(
|
|
92
|
+
result_for_model=f"Command timed out after {timeout}s",
|
|
93
|
+
is_error=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
97
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
98
|
+
exit_code = proc.returncode or 0
|
|
99
|
+
|
|
100
|
+
max_chars = 100_000
|
|
101
|
+
if len(stdout) > max_chars:
|
|
102
|
+
stdout = stdout[:max_chars] + "\n... (truncated)"
|
|
103
|
+
if len(stderr) > max_chars:
|
|
104
|
+
stderr = stderr[:max_chars] + "\n... (truncated)"
|
|
105
|
+
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
if stdout:
|
|
108
|
+
parts.append(stdout)
|
|
109
|
+
if stderr:
|
|
110
|
+
parts.append(f"stderr:\n{stderr}")
|
|
111
|
+
if exit_code != 0:
|
|
112
|
+
parts.append(f"Exit code: {exit_code}")
|
|
113
|
+
|
|
114
|
+
output = "\n".join(parts) if parts else "(no output)"
|
|
115
|
+
|
|
116
|
+
return ToolResult(
|
|
117
|
+
data={"stdout": stdout, "stderr": stderr, "exit_code": exit_code},
|
|
118
|
+
result_for_model=output,
|
|
119
|
+
result_for_display=output,
|
|
120
|
+
is_error=exit_code != 0,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return ToolResult(
|
|
125
|
+
result_for_model=f"Error executing command: {e}",
|
|
126
|
+
is_error=True,
|
|
127
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Diff utilities for file edit/write tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_diff(
|
|
9
|
+
old: str,
|
|
10
|
+
new: str,
|
|
11
|
+
filepath: str = "",
|
|
12
|
+
context_lines: int = 3,
|
|
13
|
+
) -> dict:
|
|
14
|
+
"""Compute a structured diff between old and new file content.
|
|
15
|
+
|
|
16
|
+
Returns a dict with:
|
|
17
|
+
line_range - (start, end) 1-indexed line range of first change
|
|
18
|
+
diff_text - unified diff string (compact, for model)
|
|
19
|
+
display - formatted diff string (for terminal display)
|
|
20
|
+
stats - {"added": N, "removed": N}
|
|
21
|
+
"""
|
|
22
|
+
old_lines = old.splitlines(keepends=True)
|
|
23
|
+
new_lines = new.splitlines(keepends=True)
|
|
24
|
+
|
|
25
|
+
diff_lines = list(difflib.unified_diff(
|
|
26
|
+
old_lines,
|
|
27
|
+
new_lines,
|
|
28
|
+
fromfile=filepath,
|
|
29
|
+
tofile=filepath,
|
|
30
|
+
lineterm="",
|
|
31
|
+
n=context_lines,
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
if not diff_lines:
|
|
35
|
+
return {
|
|
36
|
+
"line_range": None,
|
|
37
|
+
"diff_text": "",
|
|
38
|
+
"display": "",
|
|
39
|
+
"stats": {"added": 0, "removed": 0},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
|
43
|
+
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
|
44
|
+
|
|
45
|
+
first_change_line = _find_first_change_line(old_lines, new_lines)
|
|
46
|
+
last_change_line = _find_last_change_line(old_lines, new_lines)
|
|
47
|
+
|
|
48
|
+
diff_text = "\n".join(diff_lines)
|
|
49
|
+
|
|
50
|
+
max_diff_chars = 10_000
|
|
51
|
+
if len(diff_text) > max_diff_chars:
|
|
52
|
+
diff_text = diff_text[:max_diff_chars] + "\n... (diff truncated)"
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"line_range": (first_change_line, last_change_line),
|
|
56
|
+
"diff_text": diff_text,
|
|
57
|
+
"display": diff_text,
|
|
58
|
+
"stats": {"added": added, "removed": removed},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _find_first_change_line(old: list[str], new: list[str]) -> int:
|
|
63
|
+
"""Find the 1-indexed line number of the first difference."""
|
|
64
|
+
for i, (a, b) in enumerate(zip(old, new)):
|
|
65
|
+
if a != b:
|
|
66
|
+
return i + 1
|
|
67
|
+
return min(len(old), len(new)) + 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _find_last_change_line(old: list[str], new: list[str]) -> int:
|
|
71
|
+
"""Find the 1-indexed line number (in new file) of the last difference."""
|
|
72
|
+
old_rev = list(reversed(old))
|
|
73
|
+
new_rev = list(reversed(new))
|
|
74
|
+
tail_common = 0
|
|
75
|
+
for a, b in zip(old_rev, new_rev):
|
|
76
|
+
if a != b:
|
|
77
|
+
break
|
|
78
|
+
tail_common += 1
|
|
79
|
+
return max(1, len(new) - tail_common)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_edit_summary(
|
|
83
|
+
filepath: str,
|
|
84
|
+
diff_info: dict,
|
|
85
|
+
replacements: int = 1,
|
|
86
|
+
) -> tuple[str, str]:
|
|
87
|
+
"""Format diff info into (result_for_model, result_for_display).
|
|
88
|
+
|
|
89
|
+
Returns a pair of strings optimized for model consumption and
|
|
90
|
+
terminal display respectively.
|
|
91
|
+
"""
|
|
92
|
+
lr = diff_info["line_range"]
|
|
93
|
+
stats = diff_info["stats"]
|
|
94
|
+
|
|
95
|
+
if lr:
|
|
96
|
+
line_desc = f"lines {lr[0]}-{lr[1]}" if lr[0] != lr[1] else f"line {lr[0]}"
|
|
97
|
+
else:
|
|
98
|
+
line_desc = ""
|
|
99
|
+
|
|
100
|
+
stat_parts = []
|
|
101
|
+
if stats["added"]:
|
|
102
|
+
stat_parts.append(f"+{stats['added']}")
|
|
103
|
+
if stats["removed"]:
|
|
104
|
+
stat_parts.append(f"-{stats['removed']}")
|
|
105
|
+
stat_str = ", ".join(stat_parts)
|
|
106
|
+
|
|
107
|
+
model_msg = f"Updated {filepath}"
|
|
108
|
+
if line_desc:
|
|
109
|
+
model_msg += f" ({line_desc})"
|
|
110
|
+
if stat_str:
|
|
111
|
+
model_msg += f" [{stat_str}]"
|
|
112
|
+
if replacements > 1:
|
|
113
|
+
model_msg += f" ({replacements} replacements)"
|
|
114
|
+
|
|
115
|
+
display_parts = [model_msg]
|
|
116
|
+
if diff_info["display"]:
|
|
117
|
+
display_parts.append(diff_info["display"])
|
|
118
|
+
display_msg = "\n".join(display_parts)
|
|
119
|
+
|
|
120
|
+
return model_msg, display_msg
|