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,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