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,106 @@
|
|
|
1
|
+
"""Permission management — controls tool execution authorization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.types.config import PermissionRule, PermissionsSettings
|
|
10
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PermissionMode(str, Enum):
|
|
14
|
+
DEFAULT = "default"
|
|
15
|
+
ACCEPT_EDITS = "acceptEdits"
|
|
16
|
+
BYPASS = "bypassPermissions"
|
|
17
|
+
PLAN = "plan"
|
|
18
|
+
DONT_ASK = "dontAsk"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionManager:
|
|
22
|
+
"""Manages tool permissions based on settings and mode."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
settings: PermissionsSettings | None = None,
|
|
27
|
+
mode: PermissionMode = PermissionMode.DEFAULT,
|
|
28
|
+
):
|
|
29
|
+
self.settings = settings or PermissionsSettings()
|
|
30
|
+
if self.settings.run_everything:
|
|
31
|
+
self.mode = PermissionMode.BYPASS
|
|
32
|
+
else:
|
|
33
|
+
self.mode = mode
|
|
34
|
+
|
|
35
|
+
def check(
|
|
36
|
+
self,
|
|
37
|
+
tool: Tool,
|
|
38
|
+
tool_input: dict[str, Any],
|
|
39
|
+
) -> PermissionResult:
|
|
40
|
+
"""Check if a tool can be used with the given input."""
|
|
41
|
+
if self.mode == PermissionMode.BYPASS:
|
|
42
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
43
|
+
|
|
44
|
+
if self.mode == PermissionMode.PLAN:
|
|
45
|
+
if not tool.is_read_only:
|
|
46
|
+
return PermissionResult(
|
|
47
|
+
behavior=PermissionBehavior.DENY,
|
|
48
|
+
reason="Plan mode: write operations are not allowed",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
for rule in self.settings.deny:
|
|
52
|
+
if self._matches_rule(rule, tool, tool_input):
|
|
53
|
+
return PermissionResult(
|
|
54
|
+
behavior=PermissionBehavior.DENY,
|
|
55
|
+
reason=f"Denied by rule: {rule.tool}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for rule in self.settings.allow:
|
|
59
|
+
if self._matches_rule(rule, tool, tool_input):
|
|
60
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
61
|
+
|
|
62
|
+
for rule in self.settings.ask:
|
|
63
|
+
if self._matches_rule(rule, tool, tool_input):
|
|
64
|
+
return PermissionResult(
|
|
65
|
+
behavior=PermissionBehavior.ASK,
|
|
66
|
+
reason=f"Requires confirmation: {rule.tool}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if tool.is_read_only:
|
|
70
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
71
|
+
|
|
72
|
+
if self.mode == PermissionMode.ACCEPT_EDITS:
|
|
73
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
74
|
+
|
|
75
|
+
if self.mode == PermissionMode.DONT_ASK:
|
|
76
|
+
return PermissionResult(
|
|
77
|
+
behavior=PermissionBehavior.DENY,
|
|
78
|
+
reason="dontAsk mode: denied by default",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return PermissionResult(behavior=PermissionBehavior.ASK)
|
|
82
|
+
|
|
83
|
+
def add_allow_rule(self, tool_name: str) -> None:
|
|
84
|
+
"""Add a runtime allow rule (for 'always allow' during a session)."""
|
|
85
|
+
self.settings.allow.append(PermissionRule(tool=tool_name))
|
|
86
|
+
|
|
87
|
+
def _matches_rule(
|
|
88
|
+
self,
|
|
89
|
+
rule: PermissionRule,
|
|
90
|
+
tool: Tool,
|
|
91
|
+
tool_input: dict[str, Any],
|
|
92
|
+
) -> bool:
|
|
93
|
+
if rule.tool != tool.name and rule.tool != "*":
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if rule.path:
|
|
97
|
+
file_path = tool_input.get("file_path", "") or tool_input.get("path", "")
|
|
98
|
+
if file_path and not fnmatch.fnmatch(file_path, rule.path):
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
if rule.command:
|
|
102
|
+
command = tool_input.get("command", "")
|
|
103
|
+
if command and not fnmatch.fnmatch(command, rule.command):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Context assembly — ported from src/context.ts.
|
|
2
|
+
|
|
3
|
+
Provides getSystemContext (git status etc.) and getUserContext (CLAUDE.md, date).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _run_git(*args: str, cwd: str = ".") -> str:
|
|
17
|
+
"""Run a git command and return stdout, or empty string on failure."""
|
|
18
|
+
try:
|
|
19
|
+
proc = await asyncio.create_subprocess_exec(
|
|
20
|
+
"git", *args,
|
|
21
|
+
stdout=asyncio.subprocess.PIPE,
|
|
22
|
+
stderr=asyncio.subprocess.PIPE,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
)
|
|
25
|
+
stdout, _ = await proc.communicate()
|
|
26
|
+
return stdout.decode().strip()
|
|
27
|
+
except Exception:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _is_git_repo(cwd: str = ".") -> bool:
|
|
32
|
+
result = await _run_git("rev-parse", "--is-inside-work-tree", cwd=cwd)
|
|
33
|
+
return result == "true"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_git_status(cwd: str = ".") -> str | None:
|
|
37
|
+
"""Build the git status context block."""
|
|
38
|
+
is_git = await _is_git_repo(cwd)
|
|
39
|
+
if not is_git:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
branch, main_branch, status, log, user_name = await asyncio.gather(
|
|
44
|
+
_run_git("branch", "--show-current", cwd=cwd),
|
|
45
|
+
_run_git("rev-parse", "--abbrev-ref", "origin/HEAD", cwd=cwd),
|
|
46
|
+
_run_git("--no-optional-locks", "status", "--short", cwd=cwd),
|
|
47
|
+
_run_git("--no-optional-locks", "log", "--oneline", "-n", "5", cwd=cwd),
|
|
48
|
+
_run_git("config", "user.name", cwd=cwd),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
main_branch = main_branch.replace("origin/", "") if main_branch else "main"
|
|
52
|
+
|
|
53
|
+
max_chars = 2000
|
|
54
|
+
if len(status) > max_chars:
|
|
55
|
+
status = (
|
|
56
|
+
status[:max_chars]
|
|
57
|
+
+ '\n... (truncated because it exceeds 2k characters. '
|
|
58
|
+
'If you need more information, run "git status" using BashTool)'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parts = [
|
|
62
|
+
"This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.",
|
|
63
|
+
f"Current branch: {branch}",
|
|
64
|
+
f"Main branch (you will usually use this for PRs): {main_branch}",
|
|
65
|
+
]
|
|
66
|
+
if user_name:
|
|
67
|
+
parts.append(f"Git user: {user_name}")
|
|
68
|
+
parts.append(f"Status:\n{status or '(clean)'}")
|
|
69
|
+
parts.append(f"Recent commits:\n{log}")
|
|
70
|
+
|
|
71
|
+
return "\n\n".join(parts)
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_system_context(cwd: str = ".") -> dict[str, str]:
|
|
77
|
+
"""Build system context (appended to system prompt).
|
|
78
|
+
|
|
79
|
+
Synchronous wrapper — runs git commands in event loop if available.
|
|
80
|
+
For the initial version, we do a simplified synchronous git check.
|
|
81
|
+
"""
|
|
82
|
+
context: dict[str, str] = {}
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
87
|
+
capture_output=True, text=True, cwd=cwd, timeout=5,
|
|
88
|
+
)
|
|
89
|
+
is_git = result.stdout.strip() == "true"
|
|
90
|
+
except Exception:
|
|
91
|
+
is_git = False
|
|
92
|
+
|
|
93
|
+
if is_git:
|
|
94
|
+
try:
|
|
95
|
+
parts = []
|
|
96
|
+
parts.append(
|
|
97
|
+
"This is the git status at the start of the conversation. "
|
|
98
|
+
"Note that this status is a snapshot in time, and will not "
|
|
99
|
+
"update during the conversation."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
branch = subprocess.run(
|
|
103
|
+
["git", "branch", "--show-current"],
|
|
104
|
+
capture_output=True, text=True, cwd=cwd, timeout=5,
|
|
105
|
+
).stdout.strip()
|
|
106
|
+
parts.append(f"Current branch: {branch}")
|
|
107
|
+
|
|
108
|
+
status = subprocess.run(
|
|
109
|
+
["git", "--no-optional-locks", "status", "--short"],
|
|
110
|
+
capture_output=True, text=True, cwd=cwd, timeout=5,
|
|
111
|
+
).stdout.strip()
|
|
112
|
+
parts.append(f"Status:\n{status or '(clean)'}")
|
|
113
|
+
|
|
114
|
+
log = subprocess.run(
|
|
115
|
+
["git", "--no-optional-locks", "log", "--oneline", "-n", "5"],
|
|
116
|
+
capture_output=True, text=True, cwd=cwd, timeout=5,
|
|
117
|
+
).stdout.strip()
|
|
118
|
+
parts.append(f"Recent commits:\n{log}")
|
|
119
|
+
|
|
120
|
+
context["gitStatus"] = "\n\n".join(parts)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return context
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_user_context(cwd: str = ".") -> dict[str, str]:
|
|
128
|
+
"""Build user context (prepended as first user message).
|
|
129
|
+
|
|
130
|
+
Contains CLAUDE.md content, persistent memories, and current date.
|
|
131
|
+
"""
|
|
132
|
+
context: dict[str, str] = {}
|
|
133
|
+
|
|
134
|
+
context["currentDate"] = f"Today's date is {date.today().isoformat()}."
|
|
135
|
+
|
|
136
|
+
claude_md = _load_claude_md(cwd)
|
|
137
|
+
if claude_md:
|
|
138
|
+
context["claudeMd"] = claude_md
|
|
139
|
+
|
|
140
|
+
memories_text = _load_memories_context(cwd)
|
|
141
|
+
if memories_text:
|
|
142
|
+
context["memories"] = memories_text
|
|
143
|
+
|
|
144
|
+
return context
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_claude_md(cwd: str) -> str | None:
|
|
148
|
+
"""Discover and load CLAUDE.md files from project hierarchy."""
|
|
149
|
+
contents: list[str] = []
|
|
150
|
+
|
|
151
|
+
search_dir = Path(cwd).resolve()
|
|
152
|
+
home = Path.home()
|
|
153
|
+
|
|
154
|
+
home_claude_md = home / ".claude" / "CLAUDE.md"
|
|
155
|
+
if home_claude_md.exists():
|
|
156
|
+
try:
|
|
157
|
+
contents.append(home_claude_md.read_text(errors="replace"))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
crabcode_md = home / ".crabcode" / "CLAUDE.md"
|
|
162
|
+
if crabcode_md.exists():
|
|
163
|
+
try:
|
|
164
|
+
contents.append(crabcode_md.read_text(errors="replace"))
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
current = search_dir
|
|
169
|
+
project_files: list[str] = []
|
|
170
|
+
while current != current.parent:
|
|
171
|
+
for name in ["CLAUDE.md", ".claude/CLAUDE.md"]:
|
|
172
|
+
candidate = current / name
|
|
173
|
+
if candidate.exists():
|
|
174
|
+
try:
|
|
175
|
+
project_files.append(candidate.read_text(errors="replace"))
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
if (current / ".git").exists():
|
|
179
|
+
break
|
|
180
|
+
current = current.parent
|
|
181
|
+
|
|
182
|
+
contents.extend(reversed(project_files))
|
|
183
|
+
|
|
184
|
+
if not contents:
|
|
185
|
+
return None
|
|
186
|
+
return "\n\n---\n\n".join(contents)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _load_memories_context(cwd: str) -> str | None:
|
|
190
|
+
"""Load persistent memories and format them for context injection."""
|
|
191
|
+
try:
|
|
192
|
+
from crabcode_core.tools.memory import load_all_memories
|
|
193
|
+
except ImportError:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
memories = load_all_memories(cwd)
|
|
197
|
+
if not memories:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
lines = []
|
|
201
|
+
for m in memories:
|
|
202
|
+
scope = m.pop("_scope", "?")
|
|
203
|
+
lines.append(f"- [{scope}] {m['title']}: {m['content']}")
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
"The following memories were saved from previous conversations. "
|
|
207
|
+
"They may or may not be relevant to the current task.\n\n"
|
|
208
|
+
+ "\n".join(lines)
|
|
209
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Prompt profile — configurable prompt templates for different agent personas.
|
|
2
|
+
|
|
3
|
+
Each section field controls one part of the system prompt:
|
|
4
|
+
- None → use the built-in default for that section
|
|
5
|
+
- "" → disable (skip) the section entirely
|
|
6
|
+
- "..." → override with custom text
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from crabcode_core.prompts.templates import DEFAULT_AGENT_PROMPT, DEFAULT_PREFIX
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PromptProfile(BaseModel):
|
|
17
|
+
"""Configurable prompt profile that separates identity/behavior from engine logic."""
|
|
18
|
+
|
|
19
|
+
# Agent identity line — used to build the default intro section.
|
|
20
|
+
prefix: str = DEFAULT_PREFIX
|
|
21
|
+
|
|
22
|
+
# ── Behavioral sections (domain-specific, the main things to swap) ──
|
|
23
|
+
|
|
24
|
+
intro: str | None = None
|
|
25
|
+
system: str | None = None
|
|
26
|
+
doing_tasks: str | None = None
|
|
27
|
+
actions: str | None = None
|
|
28
|
+
git_safety: str | None = None
|
|
29
|
+
using_tools: str | None = None
|
|
30
|
+
tone_and_style: str | None = None
|
|
31
|
+
output_efficiency: str | None = None
|
|
32
|
+
session_guidance: str | None = None
|
|
33
|
+
|
|
34
|
+
# Sub-agent system prompt.
|
|
35
|
+
agent_prompt: str | None = None
|
|
36
|
+
|
|
37
|
+
# Extra sections appended after all built-in sections
|
|
38
|
+
# (before the dynamic env / language / mcp blocks).
|
|
39
|
+
extra_sections: list[str] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
model_config = {"extra": "allow"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_profile() -> PromptProfile:
|
|
45
|
+
"""The default coding-assistant profile (all built-in defaults)."""
|
|
46
|
+
return PromptProfile()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def minimal_profile() -> PromptProfile:
|
|
50
|
+
"""A minimal profile with coding-specific sections stripped out.
|
|
51
|
+
|
|
52
|
+
Useful as a starting point for non-coding agents.
|
|
53
|
+
"""
|
|
54
|
+
return PromptProfile(
|
|
55
|
+
prefix="You are a helpful AI assistant.",
|
|
56
|
+
doing_tasks="",
|
|
57
|
+
actions="",
|
|
58
|
+
git_safety="",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_agent_prompt(profile: PromptProfile | None) -> str:
|
|
63
|
+
"""Return the sub-agent system prompt, respecting profile overrides."""
|
|
64
|
+
if profile and profile.agent_prompt is not None:
|
|
65
|
+
return profile.agent_prompt
|
|
66
|
+
return DEFAULT_AGENT_PROMPT
|