nullabot 1.0.1__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.
@@ -0,0 +1,13 @@
1
+ """Core modules for Nullabot - Uses Claude Code CLI"""
2
+
3
+ from nullabot.core.project import Project, ProjectManager
4
+ from nullabot.core.state import AgentState, Checkpoint
5
+ from nullabot.core.sandbox import Sandbox
6
+
7
+ __all__ = [
8
+ "Project",
9
+ "ProjectManager",
10
+ "AgentState",
11
+ "Checkpoint",
12
+ "Sandbox",
13
+ ]
@@ -0,0 +1,303 @@
1
+ """
2
+ Claude Code CLI Client - Uses claude command instead of API.
3
+
4
+ This allows Nullabot to use your Claude Code subscription ($200/month)
5
+ instead of requiring a separate Anthropic API key.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class ClaudeResponse(BaseModel):
19
+ """Response from Claude Code CLI."""
20
+
21
+ content: str
22
+ tool_calls: list[dict[str, Any]] = []
23
+ cost_usd: float = 0.0
24
+ duration_ms: int = 0
25
+
26
+
27
+ class ClaudeCodeClient:
28
+ """
29
+ Client that uses Claude Code CLI as the backend.
30
+
31
+ Instead of calling Anthropic API directly, this spawns
32
+ `claude` processes to use your Claude Code subscription.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ working_dir: Optional[Path] = None,
38
+ model: str = "opus", # opus, sonnet, haiku
39
+ max_turns: int = 1,
40
+ ):
41
+ """
42
+ Initialize Claude Code client.
43
+
44
+ Args:
45
+ working_dir: Directory for Claude to work in (sandbox)
46
+ model: Model to use (opus, sonnet, haiku)
47
+ max_turns: Max conversation turns per request
48
+ """
49
+ self.working_dir = working_dir or Path.cwd()
50
+ self.model = model
51
+ self.max_turns = max_turns
52
+
53
+ # Verify claude is installed
54
+ self._verify_claude_installed()
55
+
56
+ def _verify_claude_installed(self) -> None:
57
+ """Check if claude CLI is available."""
58
+ try:
59
+ result = subprocess.run(
60
+ ["claude", "--version"],
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=10,
64
+ )
65
+ if result.returncode != 0:
66
+ raise RuntimeError("claude command failed")
67
+ except FileNotFoundError:
68
+ raise RuntimeError(
69
+ "Claude Code CLI not found. Install it first:\n"
70
+ " npm install -g @anthropic-ai/claude-code"
71
+ )
72
+
73
+ def _build_command(
74
+ self,
75
+ prompt: str,
76
+ system_prompt: Optional[str] = None,
77
+ allowed_tools: Optional[list[str]] = None,
78
+ ) -> list[str]:
79
+ """Build the claude command."""
80
+ cmd = [
81
+ "claude",
82
+ "-p", # Print mode (non-interactive)
83
+ "--output-format", "json", # JSON output for parsing
84
+ "--model", self.model,
85
+ "--max-turns", str(self.max_turns),
86
+ ]
87
+
88
+ # Add allowed tools if specified
89
+ if allowed_tools:
90
+ cmd.extend(["--allowedTools", ",".join(allowed_tools)])
91
+
92
+ # Add the prompt
93
+ cmd.append(prompt)
94
+
95
+ return cmd
96
+
97
+ def _parse_response(self, output: str) -> ClaudeResponse:
98
+ """Parse JSON output from claude CLI."""
99
+ try:
100
+ # Claude outputs JSON with result
101
+ data = json.loads(output)
102
+
103
+ # Extract content from response
104
+ content = ""
105
+ tool_calls = []
106
+
107
+ if isinstance(data, dict):
108
+ # Handle different response formats
109
+ if "result" in data:
110
+ content = data["result"]
111
+ elif "content" in data:
112
+ content = data["content"]
113
+ elif "message" in data:
114
+ content = data.get("message", "")
115
+ else:
116
+ content = str(data)
117
+
118
+ # Extract cost info if available
119
+ cost = data.get("cost_usd", 0.0)
120
+ duration = data.get("duration_ms", 0)
121
+ else:
122
+ content = str(data)
123
+ cost = 0.0
124
+ duration = 0
125
+
126
+ return ClaudeResponse(
127
+ content=content,
128
+ tool_calls=tool_calls,
129
+ cost_usd=cost,
130
+ duration_ms=duration,
131
+ )
132
+ except json.JSONDecodeError:
133
+ # If not JSON, treat as plain text
134
+ return ClaudeResponse(content=output.strip())
135
+
136
+ def chat(
137
+ self,
138
+ prompt: str,
139
+ system_prompt: Optional[str] = None,
140
+ allowed_tools: Optional[list[str]] = None,
141
+ timeout: int = 300,
142
+ ) -> ClaudeResponse:
143
+ """
144
+ Send a prompt to Claude Code CLI.
145
+
146
+ Args:
147
+ prompt: The prompt to send
148
+ system_prompt: Optional system prompt (prepended to prompt)
149
+ allowed_tools: List of allowed tools (None = all tools)
150
+ timeout: Timeout in seconds
151
+
152
+ Returns:
153
+ ClaudeResponse with content and metadata
154
+ """
155
+ # Combine system prompt with user prompt if provided
156
+ full_prompt = prompt
157
+ if system_prompt:
158
+ full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
159
+
160
+ cmd = self._build_command(full_prompt, system_prompt, allowed_tools)
161
+
162
+ try:
163
+ result = subprocess.run(
164
+ cmd,
165
+ capture_output=True,
166
+ text=True,
167
+ cwd=str(self.working_dir),
168
+ timeout=timeout,
169
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "nullabot"},
170
+ )
171
+
172
+ if result.returncode != 0:
173
+ error_msg = result.stderr or result.stdout or "Unknown error"
174
+ return ClaudeResponse(content=f"Error: {error_msg}")
175
+
176
+ return self._parse_response(result.stdout)
177
+
178
+ except subprocess.TimeoutExpired:
179
+ return ClaudeResponse(content="Error: Request timed out")
180
+ except Exception as e:
181
+ return ClaudeResponse(content=f"Error: {str(e)}")
182
+
183
+ async def chat_async(
184
+ self,
185
+ prompt: str,
186
+ system_prompt: Optional[str] = None,
187
+ allowed_tools: Optional[list[str]] = None,
188
+ timeout: int = 300,
189
+ ) -> ClaudeResponse:
190
+ """Async version of chat."""
191
+ # Combine system prompt with user prompt if provided
192
+ full_prompt = prompt
193
+ if system_prompt:
194
+ full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
195
+
196
+ cmd = self._build_command(full_prompt, system_prompt, allowed_tools)
197
+
198
+ try:
199
+ process = await asyncio.create_subprocess_exec(
200
+ *cmd,
201
+ stdout=asyncio.subprocess.PIPE,
202
+ stderr=asyncio.subprocess.PIPE,
203
+ cwd=str(self.working_dir),
204
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "nullabot"},
205
+ )
206
+
207
+ stdout, stderr = await asyncio.wait_for(
208
+ process.communicate(),
209
+ timeout=timeout,
210
+ )
211
+
212
+ if process.returncode != 0:
213
+ error_msg = stderr.decode() or stdout.decode() or "Unknown error"
214
+ return ClaudeResponse(content=f"Error: {error_msg}")
215
+
216
+ return self._parse_response(stdout.decode())
217
+
218
+ except asyncio.TimeoutError:
219
+ return ClaudeResponse(content="Error: Request timed out")
220
+ except Exception as e:
221
+ return ClaudeResponse(content=f"Error: {str(e)}")
222
+
223
+
224
+ class ClaudeCodeAgent:
225
+ """
226
+ Simplified agent that lets Claude Code do the heavy lifting.
227
+
228
+ Instead of managing tools ourselves, we let Claude Code
229
+ handle file operations directly in the sandboxed workspace.
230
+ """
231
+
232
+ def __init__(
233
+ self,
234
+ workspace: Path,
235
+ model: str = "opus",
236
+ ):
237
+ self.workspace = workspace.resolve()
238
+ self.workspace.mkdir(parents=True, exist_ok=True)
239
+
240
+ self.client = ClaudeCodeClient(
241
+ working_dir=self.workspace,
242
+ model=model,
243
+ max_turns=5, # Allow multiple turns for complex tasks
244
+ )
245
+
246
+ # State file for persistence
247
+ self.state_file = self.workspace / ".nullabot_state.json"
248
+
249
+ def _load_state(self) -> dict:
250
+ """Load agent state."""
251
+ if self.state_file.exists():
252
+ return json.loads(self.state_file.read_text())
253
+ return {"history": [], "checkpoint": None}
254
+
255
+ def _save_state(self, state: dict) -> None:
256
+ """Save agent state."""
257
+ self.state_file.write_text(json.dumps(state, indent=2, default=str))
258
+
259
+ async def run(
260
+ self,
261
+ task: str,
262
+ system_prompt: Optional[str] = None,
263
+ on_output: Optional[callable] = None,
264
+ ) -> str:
265
+ """
266
+ Run a task using Claude Code.
267
+
268
+ Args:
269
+ task: What to do
270
+ system_prompt: Custom system prompt
271
+ on_output: Callback for streaming output
272
+
273
+ Returns:
274
+ Final response content
275
+ """
276
+ state = self._load_state()
277
+
278
+ # Build context from history
279
+ context = ""
280
+ if state.get("checkpoint"):
281
+ context = f"\n\nPrevious progress:\n{state['checkpoint']}\n\nContinue from where you left off.\n"
282
+
283
+ full_task = f"{task}{context}"
284
+
285
+ # Run Claude
286
+ response = await self.client.chat_async(
287
+ prompt=full_task,
288
+ system_prompt=system_prompt,
289
+ allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
290
+ )
291
+
292
+ # Update state
293
+ state["history"].append({
294
+ "task": task,
295
+ "response": response.content[:500], # Truncate for storage
296
+ })
297
+ state["checkpoint"] = response.content[:1000]
298
+ self._save_state(state)
299
+
300
+ if on_output:
301
+ on_output(response.content)
302
+
303
+ return response.content