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,145 @@
1
+ """FileEditTool — string replacement editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
9
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
10
+
11
+
12
+ class FileEditTool(Tool):
13
+ name = "Edit"
14
+ description = "Edit a file by replacing a specific string."
15
+ is_read_only = False
16
+ is_concurrency_safe = False
17
+ input_schema = {
18
+ "type": "object",
19
+ "properties": {
20
+ "file_path": {
21
+ "type": "string",
22
+ "description": "The path of the file to edit.",
23
+ },
24
+ "old_string": {
25
+ "type": "string",
26
+ "description": "The exact string to find and replace.",
27
+ },
28
+ "new_string": {
29
+ "type": "string",
30
+ "description": "The replacement string.",
31
+ },
32
+ "replace_all": {
33
+ "type": "boolean",
34
+ "description": "Replace all occurrences (default: false).",
35
+ },
36
+ },
37
+ "required": ["file_path", "old_string", "new_string"],
38
+ }
39
+
40
+ async def get_prompt(self, **kwargs: Any) -> str:
41
+ return (
42
+ "Edit a file by performing exact string replacements. "
43
+ "The old_string must uniquely identify the text to replace — "
44
+ "if it matches multiple locations, the edit will FAIL. Include "
45
+ "surrounding context lines to make the match unique. Preserves "
46
+ "exact indentation (tabs/spaces) as it appears in the file. "
47
+ "Use replace_all=true to replace all occurrences (useful for "
48
+ "renaming a variable across the file).\n\n"
49
+ "IMPORTANT: You MUST read the file before editing it. Never "
50
+ "guess the file content.\n\n"
51
+ "GOOD example — unique match with context:\n"
52
+ ' old_string: "def calculate(a, b):\\n return a + b"\n'
53
+ ' new_string: "def calculate(a, b):\\n return a * b"\n\n'
54
+ "BAD example — too short, likely matches multiple locations:\n"
55
+ ' old_string: "return"\n'
56
+ ' new_string: "return None"'
57
+ )
58
+
59
+ async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
60
+ if tool_input.get("old_string") == tool_input.get("new_string"):
61
+ return "old_string and new_string must be different"
62
+ return None
63
+
64
+ async def call(
65
+ self,
66
+ tool_input: dict[str, Any],
67
+ context: ToolContext,
68
+ ) -> ToolResult:
69
+ file_path = tool_input.get("file_path", "")
70
+ old_string = tool_input.get("old_string", "")
71
+ new_string = tool_input.get("new_string", "")
72
+ replace_all = tool_input.get("replace_all", False)
73
+
74
+ if not file_path:
75
+ return ToolResult(
76
+ result_for_model="Error: file_path is required",
77
+ is_error=True,
78
+ )
79
+
80
+ path = Path(file_path)
81
+ if not path.is_absolute():
82
+ path = Path(context.cwd) / path
83
+
84
+ if not path.exists():
85
+ return ToolResult(
86
+ result_for_model=f"Error: file not found: {path}",
87
+ is_error=True,
88
+ )
89
+
90
+ try:
91
+ content = path.read_text(errors="replace")
92
+ except Exception as e:
93
+ return ToolResult(
94
+ result_for_model=f"Error reading file: {e}",
95
+ is_error=True,
96
+ )
97
+
98
+ count = content.count(old_string)
99
+
100
+ if count == 0:
101
+ return ToolResult(
102
+ result_for_model=f"Error: old_string not found in {path}",
103
+ is_error=True,
104
+ )
105
+
106
+ if count > 1 and not replace_all:
107
+ return ToolResult(
108
+ result_for_model=(
109
+ f"Error: old_string found {count} times in {path}. "
110
+ "Use replace_all=true to replace all occurrences, "
111
+ "or provide more context to make the match unique."
112
+ ),
113
+ is_error=True,
114
+ )
115
+
116
+ if replace_all:
117
+ new_content = content.replace(old_string, new_string)
118
+ else:
119
+ new_content = content.replace(old_string, new_string, 1)
120
+
121
+ try:
122
+ path.write_text(new_content)
123
+ except Exception as e:
124
+ return ToolResult(
125
+ result_for_model=f"Error writing file: {e}",
126
+ is_error=True,
127
+ )
128
+
129
+ replaced = count if replace_all else 1
130
+
131
+ diff_info = compute_diff(content, new_content, str(path))
132
+ model_msg, display_msg = format_edit_summary(
133
+ str(path), diff_info, replacements=replaced
134
+ )
135
+
136
+ return ToolResult(
137
+ data={
138
+ "file_path": str(path),
139
+ "replacements": replaced,
140
+ "line_range": diff_info["line_range"],
141
+ "stats": diff_info["stats"],
142
+ },
143
+ result_for_model=model_msg,
144
+ result_for_display=display_msg,
145
+ )
@@ -0,0 +1,123 @@
1
+ """FileReadTool — read file contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
9
+
10
+
11
+ class FileReadTool(Tool):
12
+ name = "Read"
13
+ description = "Read the contents of a file."
14
+ is_read_only = True
15
+ is_concurrency_safe = True
16
+ input_schema = {
17
+ "type": "object",
18
+ "properties": {
19
+ "file_path": {
20
+ "type": "string",
21
+ "description": "The absolute or relative path of the file to read.",
22
+ },
23
+ "offset": {
24
+ "type": "integer",
25
+ "description": "Line number to start reading from (1-based). Negative values count from end.",
26
+ },
27
+ "limit": {
28
+ "type": "integer",
29
+ "description": "Number of lines to read.",
30
+ },
31
+ },
32
+ "required": ["file_path"],
33
+ }
34
+
35
+ async def get_prompt(self, **kwargs: Any) -> str:
36
+ return (
37
+ "Read the contents of a file from the local filesystem. "
38
+ "You can optionally specify a line offset and limit (especially "
39
+ "useful for large files), but it's recommended to read the whole "
40
+ "file by not providing these parameters.\n\n"
41
+ "Lines in the output are numbered starting at 1, in the format "
42
+ "LINE_NUMBER|LINE_CONTENT. The LINE_NUMBER prefix is metadata — "
43
+ "do NOT treat it as part of the actual code.\n\n"
44
+ "IMPORTANT: You MUST read a file at least once before editing it. "
45
+ "You can call Read on multiple files in a single response to read "
46
+ "them in parallel.\n\n"
47
+ "If the file exists but is empty, you will receive 'File is empty.'.\n"
48
+ "If the file does not exist, you will receive an error."
49
+ )
50
+
51
+ async def call(
52
+ self,
53
+ tool_input: dict[str, Any],
54
+ context: ToolContext,
55
+ ) -> ToolResult:
56
+ file_path = tool_input.get("file_path", "")
57
+ offset = tool_input.get("offset")
58
+ limit = tool_input.get("limit")
59
+
60
+ if not file_path:
61
+ return ToolResult(
62
+ result_for_model="Error: file_path is required",
63
+ is_error=True,
64
+ )
65
+
66
+ path = Path(file_path)
67
+ if not path.is_absolute():
68
+ path = Path(context.cwd) / path
69
+
70
+ if not path.exists():
71
+ return ToolResult(
72
+ result_for_model=f"Error: file not found: {path}",
73
+ is_error=True,
74
+ )
75
+
76
+ if not path.is_file():
77
+ return ToolResult(
78
+ result_for_model=f"Error: not a file: {path}",
79
+ is_error=True,
80
+ )
81
+
82
+ try:
83
+ content = path.read_text(errors="replace")
84
+ except Exception as e:
85
+ return ToolResult(
86
+ result_for_model=f"Error reading file: {e}",
87
+ is_error=True,
88
+ )
89
+
90
+ if not content:
91
+ return ToolResult(
92
+ result_for_model="File is empty.",
93
+ data={"file_path": str(path), "content": ""},
94
+ )
95
+
96
+ lines = content.splitlines(keepends=True)
97
+ total_lines = len(lines)
98
+
99
+ if offset is not None:
100
+ if offset < 0:
101
+ start = max(0, total_lines + offset)
102
+ else:
103
+ start = max(0, offset - 1)
104
+ else:
105
+ start = 0
106
+
107
+ if limit is not None:
108
+ end = min(total_lines, start + limit)
109
+ else:
110
+ end = total_lines
111
+
112
+ selected = lines[start:end]
113
+
114
+ numbered = []
115
+ for i, line in enumerate(selected, start=start + 1):
116
+ numbered.append(f"{i:6d}|{line.rstrip()}")
117
+
118
+ result_text = "\n".join(numbered)
119
+
120
+ return ToolResult(
121
+ data={"file_path": str(path), "content": content, "total_lines": total_lines},
122
+ result_for_model=result_text,
123
+ )
@@ -0,0 +1,102 @@
1
+ """FileWriteTool — create or overwrite files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from crabcode_core.tools.diff_utils import compute_diff, format_edit_summary
9
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
10
+
11
+
12
+ class FileWriteTool(Tool):
13
+ name = "Write"
14
+ description = "Write content to a file, creating it if necessary."
15
+ is_read_only = False
16
+ is_concurrency_safe = False
17
+ input_schema = {
18
+ "type": "object",
19
+ "properties": {
20
+ "file_path": {
21
+ "type": "string",
22
+ "description": "The path of the file to write.",
23
+ },
24
+ "content": {
25
+ "type": "string",
26
+ "description": "The content to write to the file.",
27
+ },
28
+ },
29
+ "required": ["file_path", "content"],
30
+ }
31
+
32
+ async def get_prompt(self, **kwargs: Any) -> str:
33
+ return (
34
+ "Write content to a file. Creates the file and parent directories "
35
+ "if they don't exist. WARNING: This will overwrite the existing "
36
+ "file if there is one.\n\n"
37
+ "ALWAYS prefer editing existing files with the Edit tool instead "
38
+ "of rewriting them. Only use Write for:\n"
39
+ "- Creating new files that don't exist yet\n"
40
+ "- Cases where the entire file content needs to be replaced\n\n"
41
+ "NEVER create files unless absolutely necessary for achieving "
42
+ "your goal. This prevents file bloat and builds on existing "
43
+ "work more effectively.\n"
44
+ "NEVER proactively create documentation or README files unless "
45
+ "the user explicitly requests them."
46
+ )
47
+
48
+ async def call(
49
+ self,
50
+ tool_input: dict[str, Any],
51
+ context: ToolContext,
52
+ ) -> ToolResult:
53
+ file_path = tool_input.get("file_path", "")
54
+ content = tool_input.get("content", "")
55
+
56
+ if not file_path:
57
+ return ToolResult(
58
+ result_for_model="Error: file_path is required",
59
+ is_error=True,
60
+ )
61
+
62
+ path = Path(file_path)
63
+ if not path.is_absolute():
64
+ path = Path(context.cwd) / path
65
+
66
+ is_new = not path.exists()
67
+ old_content = ""
68
+ if not is_new:
69
+ try:
70
+ old_content = path.read_text(errors="replace")
71
+ except Exception:
72
+ pass
73
+
74
+ try:
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(content)
77
+ except Exception as e:
78
+ return ToolResult(
79
+ result_for_model=f"Error writing file: {e}",
80
+ is_error=True,
81
+ )
82
+
83
+ if is_new:
84
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
85
+ return ToolResult(
86
+ data={"file_path": str(path), "created": True, "lines": line_count},
87
+ result_for_model=f"Created {path} ({line_count} lines).",
88
+ )
89
+
90
+ diff_info = compute_diff(old_content, content, str(path))
91
+ model_msg, display_msg = format_edit_summary(str(path), diff_info)
92
+
93
+ return ToolResult(
94
+ data={
95
+ "file_path": str(path),
96
+ "created": False,
97
+ "line_range": diff_info["line_range"],
98
+ "stats": diff_info["stats"],
99
+ },
100
+ result_for_model=model_msg,
101
+ result_for_display=display_msg,
102
+ )
@@ -0,0 +1,109 @@
1
+ """GlobTool — find files by pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import fnmatch
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
11
+
12
+
13
+ class GlobTool(Tool):
14
+ name = "Glob"
15
+ description = "Find files matching a glob pattern."
16
+ is_read_only = True
17
+ is_concurrency_safe = True
18
+ input_schema = {
19
+ "type": "object",
20
+ "properties": {
21
+ "pattern": {
22
+ "type": "string",
23
+ "description": "Glob pattern to match (e.g., '**/*.py', 'src/**/*.ts').",
24
+ },
25
+ "path": {
26
+ "type": "string",
27
+ "description": "Directory to search in (default: cwd).",
28
+ },
29
+ },
30
+ "required": ["pattern"],
31
+ }
32
+
33
+ async def get_prompt(self, **kwargs: Any) -> str:
34
+ return (
35
+ "Find files matching a glob pattern. Returns matching file paths "
36
+ "sorted by modification time (most recent first). Works fast "
37
+ "with codebases of any size.\n\n"
38
+ "Patterns not starting with '**/' are automatically prepended "
39
+ "with it for recursive searching. Use this instead of find or "
40
+ "ls via Bash.\n\n"
41
+ "Examples:\n"
42
+ '- "*.py" (becomes "**/*.py") — find all Python files\n'
43
+ '- "src/**/*.ts" — find all TypeScript files under src/\n'
44
+ '- "**/test_*.py" — find all test files'
45
+ )
46
+
47
+ async def call(
48
+ self,
49
+ tool_input: dict[str, Any],
50
+ context: ToolContext,
51
+ ) -> ToolResult:
52
+ pattern = tool_input.get("pattern", "")
53
+ search_path = tool_input.get("path", context.cwd)
54
+
55
+ if not pattern:
56
+ return ToolResult(
57
+ result_for_model="Error: pattern is required",
58
+ is_error=True,
59
+ )
60
+
61
+ if not pattern.startswith("**/") and not pattern.startswith("/"):
62
+ pattern = f"**/{pattern}"
63
+
64
+ root = Path(search_path)
65
+ if not root.is_absolute():
66
+ root = Path(context.cwd) / root
67
+
68
+ if not root.exists():
69
+ return ToolResult(
70
+ result_for_model=f"Error: directory not found: {root}",
71
+ is_error=True,
72
+ )
73
+
74
+ try:
75
+ matches: list[tuple[float, str]] = []
76
+ for path in root.glob(pattern):
77
+ if path.is_file():
78
+ try:
79
+ mtime = path.stat().st_mtime
80
+ except OSError:
81
+ mtime = 0
82
+ matches.append((mtime, str(path)))
83
+
84
+ if len(matches) > 1000:
85
+ break
86
+
87
+ matches.sort(key=lambda x: x[0], reverse=True)
88
+ file_paths = [p for _, p in matches]
89
+
90
+ except Exception as e:
91
+ return ToolResult(
92
+ result_for_model=f"Error during glob: {e}",
93
+ is_error=True,
94
+ )
95
+
96
+ if not file_paths:
97
+ return ToolResult(
98
+ result_for_model="No files matched the pattern.",
99
+ data={"count": 0},
100
+ )
101
+
102
+ result_text = "\n".join(file_paths)
103
+ count = len(file_paths)
104
+ suffix = " (showing first 1000)" if count >= 1000 else ""
105
+
106
+ return ToolResult(
107
+ data={"count": count, "files": file_paths},
108
+ result_for_model=f"Found {count} files{suffix}:\n{result_text}",
109
+ )
@@ -0,0 +1,130 @@
1
+ """GrepTool — search file contents using ripgrep."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shutil
7
+ from typing import Any
8
+
9
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
10
+
11
+
12
+ class GrepTool(Tool):
13
+ name = "Grep"
14
+ description = "Search file contents using regex patterns."
15
+ is_read_only = True
16
+ is_concurrency_safe = True
17
+ input_schema = {
18
+ "type": "object",
19
+ "properties": {
20
+ "pattern": {
21
+ "type": "string",
22
+ "description": "The regex pattern to search for.",
23
+ },
24
+ "path": {
25
+ "type": "string",
26
+ "description": "Directory or file to search in (default: cwd).",
27
+ },
28
+ "glob": {
29
+ "type": "string",
30
+ "description": "File glob pattern to filter (e.g., '*.py').",
31
+ },
32
+ "case_insensitive": {
33
+ "type": "boolean",
34
+ "description": "Case insensitive search.",
35
+ },
36
+ },
37
+ "required": ["pattern"],
38
+ }
39
+
40
+ async def get_prompt(self, **kwargs: Any) -> str:
41
+ return (
42
+ "Search file contents for a regex pattern using ripgrep. "
43
+ "Returns matching lines with file paths and line numbers. "
44
+ "Use this instead of running grep or rg via Bash.\n\n"
45
+ "Supports full regex syntax (e.g., 'log.*Error', "
46
+ "'function\\s+\\w+'). Filter files with the glob parameter "
47
+ "(e.g., '*.py', '*.tsx').\n\n"
48
+ "Results are capped at 200 matches for responsiveness. "
49
+ "If you need more specific results, narrow your search "
50
+ "with a more specific pattern or glob filter."
51
+ )
52
+
53
+ async def call(
54
+ self,
55
+ tool_input: dict[str, Any],
56
+ context: ToolContext,
57
+ ) -> ToolResult:
58
+ pattern = tool_input.get("pattern", "")
59
+ search_path = tool_input.get("path", context.cwd)
60
+ glob_pattern = tool_input.get("glob")
61
+ case_insensitive = tool_input.get("case_insensitive", False)
62
+
63
+ if not pattern:
64
+ return ToolResult(
65
+ result_for_model="Error: pattern is required",
66
+ is_error=True,
67
+ )
68
+
69
+ rg = shutil.which("rg")
70
+ if not rg:
71
+ rg = "rg"
72
+
73
+ args = [rg, "--line-number", "--no-heading", "--color=never"]
74
+
75
+ if case_insensitive:
76
+ args.append("-i")
77
+ if glob_pattern:
78
+ args.extend(["--glob", glob_pattern])
79
+
80
+ args.extend(["--max-count", "200"])
81
+ args.append(pattern)
82
+ args.append("--")
83
+ args.append(search_path)
84
+
85
+ try:
86
+ proc = await asyncio.create_subprocess_exec(
87
+ *args,
88
+ stdout=asyncio.subprocess.PIPE,
89
+ stderr=asyncio.subprocess.PIPE,
90
+ cwd=context.cwd,
91
+ )
92
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
93
+ proc.communicate(), timeout=30
94
+ )
95
+ except asyncio.TimeoutError:
96
+ return ToolResult(
97
+ result_for_model="Search timed out after 30s",
98
+ is_error=True,
99
+ )
100
+ except FileNotFoundError:
101
+ return ToolResult(
102
+ result_for_model="Error: ripgrep (rg) not found. Please install it.",
103
+ is_error=True,
104
+ )
105
+
106
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
107
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
108
+
109
+ if proc.returncode == 1:
110
+ return ToolResult(
111
+ result_for_model="No matches found.",
112
+ data={"matches": 0},
113
+ )
114
+
115
+ if proc.returncode and proc.returncode > 1:
116
+ return ToolResult(
117
+ result_for_model=f"Grep error: {stderr or 'unknown error'}",
118
+ is_error=True,
119
+ )
120
+
121
+ lines = stdout.strip().split("\n") if stdout.strip() else []
122
+
123
+ max_output = 50_000
124
+ if len(stdout) > max_output:
125
+ stdout = stdout[:max_output] + "\n... (truncated)"
126
+
127
+ return ToolResult(
128
+ data={"match_count": len(lines)},
129
+ result_for_model=stdout or "No matches found.",
130
+ )