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.
- nullabot/__init__.py +3 -0
- nullabot/agents/__init__.py +7 -0
- nullabot/agents/claude_agent.py +785 -0
- nullabot/bot/__init__.py +5 -0
- nullabot/bot/telegram.py +1729 -0
- nullabot/cli.py +740 -0
- nullabot/core/__init__.py +13 -0
- nullabot/core/claude_code.py +303 -0
- nullabot/core/memory.py +864 -0
- nullabot/core/project.py +194 -0
- nullabot/core/rate_limiter.py +484 -0
- nullabot/core/reliability.py +420 -0
- nullabot/core/sandbox.py +143 -0
- nullabot/core/state.py +214 -0
- nullabot-1.0.1.dist-info/METADATA +130 -0
- nullabot-1.0.1.dist-info/RECORD +19 -0
- nullabot-1.0.1.dist-info/WHEEL +4 -0
- nullabot-1.0.1.dist-info/entry_points.txt +2 -0
- nullabot-1.0.1.dist-info/licenses/LICENSE +21 -0
nullabot/cli.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nullabot CLI - Command line interface.
|
|
3
|
+
|
|
4
|
+
Uses Claude Code CLI as the backend (your subscription).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
nullabot setup Interactive setup wizard
|
|
8
|
+
nullabot new <name> Create new project
|
|
9
|
+
nullabot think <project> Start Thinker agent
|
|
10
|
+
nullabot design <project> Start Designer agent
|
|
11
|
+
nullabot code <project> Start Coder agent
|
|
12
|
+
nullabot status Show status
|
|
13
|
+
nullabot bot Start Telegram bot
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
import yaml
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
from rich.panel import Panel
|
|
30
|
+
from rich.prompt import Prompt, Confirm
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
# Default projects directory
|
|
35
|
+
DEFAULT_PROJECTS_DIR = Path.cwd() / "projects"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_projects_dir(projects_dir: str | None = None) -> Path:
|
|
39
|
+
"""Get projects directory from arg, config, or default."""
|
|
40
|
+
if projects_dir:
|
|
41
|
+
return Path(projects_dir)
|
|
42
|
+
|
|
43
|
+
# Try to load from config.yaml in current dir or package dir
|
|
44
|
+
for config_path in [Path.cwd() / "config.yaml", Path(__file__).parent.parent / "config.yaml"]:
|
|
45
|
+
if config_path.exists():
|
|
46
|
+
try:
|
|
47
|
+
config = yaml.safe_load(config_path.read_text())
|
|
48
|
+
cfg_projects = config.get("paths", {}).get("projects_dir")
|
|
49
|
+
if cfg_projects:
|
|
50
|
+
# Resolve relative to config file location
|
|
51
|
+
p = Path(cfg_projects)
|
|
52
|
+
if not p.is_absolute():
|
|
53
|
+
p = config_path.parent / p
|
|
54
|
+
return p.resolve()
|
|
55
|
+
except:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return DEFAULT_PROJECTS_DIR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_project_path(projects_dir: Path, name: str) -> Path:
|
|
62
|
+
"""Get path to a project."""
|
|
63
|
+
return projects_dir / name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_all_projects(projects_dir: Path) -> list[Path]:
|
|
67
|
+
"""List all project directories."""
|
|
68
|
+
if not projects_dir.exists():
|
|
69
|
+
return []
|
|
70
|
+
return [
|
|
71
|
+
p for p in projects_dir.iterdir()
|
|
72
|
+
if p.is_dir() and (p / ".nullabot").exists()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@click.group()
|
|
77
|
+
@click.option(
|
|
78
|
+
"--projects-dir",
|
|
79
|
+
envvar="NULLA_PROJECTS_DIR",
|
|
80
|
+
help="Projects directory (default: ./projects)",
|
|
81
|
+
)
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def cli(ctx, projects_dir: str | None):
|
|
84
|
+
"""Nullabot - 24/7 AI Workforce (powered by Claude Code)"""
|
|
85
|
+
ctx.ensure_object(dict)
|
|
86
|
+
ctx.obj["projects_dir"] = get_projects_dir(projects_dir)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@cli.command("setup")
|
|
90
|
+
def setup_wizard():
|
|
91
|
+
"""Interactive setup wizard - run this first!"""
|
|
92
|
+
console.print(Panel(
|
|
93
|
+
"[bold cyan]Welcome to Nullabot Setup![/bold cyan]\n\n"
|
|
94
|
+
"This wizard will help you configure everything step by step.",
|
|
95
|
+
title="đ Nullabot Setup",
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
# Check for existing config
|
|
99
|
+
config_path = Path.cwd() / "config.yaml"
|
|
100
|
+
existing_config = None
|
|
101
|
+
if config_path.exists():
|
|
102
|
+
try:
|
|
103
|
+
existing_config = yaml.safe_load(config_path.read_text())
|
|
104
|
+
console.print("\n[green]â[/green] Found existing config.yaml")
|
|
105
|
+
except:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# Step 1: Check Python
|
|
109
|
+
console.print("\n[bold]Step 1/5: Checking Python...[/bold]")
|
|
110
|
+
python_version = sys.version_info
|
|
111
|
+
if python_version >= (3, 11):
|
|
112
|
+
console.print(f" [green]â[/green] Python {python_version.major}.{python_version.minor} installed")
|
|
113
|
+
else:
|
|
114
|
+
console.print(f" [red]â[/red] Python 3.11+ required (you have {python_version.major}.{python_version.minor})")
|
|
115
|
+
console.print(" Install Python 3.11+: https://python.org/downloads")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
# Step 2: Check Claude Code CLI
|
|
119
|
+
console.print("\n[bold]Step 2/5: Checking Claude Code CLI...[/bold]")
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=10)
|
|
122
|
+
if result.returncode == 0:
|
|
123
|
+
version = result.stdout.strip() or "installed"
|
|
124
|
+
console.print(f" [green]â[/green] Claude Code CLI {version}")
|
|
125
|
+
else:
|
|
126
|
+
raise FileNotFoundError()
|
|
127
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
128
|
+
console.print(" [red]â[/red] Claude Code CLI not found")
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(" [bold]Install Claude Code CLI:[/bold]")
|
|
131
|
+
console.print(" 1. npm install -g @anthropic-ai/claude-code")
|
|
132
|
+
console.print(" 2. claude login")
|
|
133
|
+
console.print()
|
|
134
|
+
console.print(" [dim]Requires Claude Max subscription ($100-$200/month)[/dim]")
|
|
135
|
+
|
|
136
|
+
if not Confirm.ask(" Continue setup anyway?", default=False):
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
|
|
139
|
+
# Step 3: Telegram Setup
|
|
140
|
+
console.print("\n[bold]Step 3/5: Telegram Setup[/bold]")
|
|
141
|
+
console.print(" [dim]Control your agents remotely via Telegram[/dim]")
|
|
142
|
+
|
|
143
|
+
# Check for existing config
|
|
144
|
+
existing_token = None
|
|
145
|
+
existing_admin = None
|
|
146
|
+
existing_users = []
|
|
147
|
+
if existing_config:
|
|
148
|
+
telegram_cfg = existing_config.get("telegram", {})
|
|
149
|
+
existing_token = telegram_cfg.get("bot_token")
|
|
150
|
+
if existing_token and existing_token.startswith("$"):
|
|
151
|
+
existing_token = None # Placeholder, not real token
|
|
152
|
+
existing_admin = telegram_cfg.get("admins", [None])[0] if telegram_cfg.get("admins") else telegram_cfg.get("user_id")
|
|
153
|
+
existing_users = telegram_cfg.get("allowed_users", [])
|
|
154
|
+
|
|
155
|
+
use_telegram = Confirm.ask(" Do you want to use Telegram?", default=True)
|
|
156
|
+
bot_token = None
|
|
157
|
+
admin_id = None
|
|
158
|
+
allowed_users = []
|
|
159
|
+
|
|
160
|
+
if use_telegram:
|
|
161
|
+
# Bot token
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(" [bold]Step 3a: Create a Telegram Bot[/bold]")
|
|
164
|
+
console.print(" 1. Open Telegram, search @BotFather")
|
|
165
|
+
console.print(" 2. Send /newbot, follow the prompts")
|
|
166
|
+
console.print(" 3. Copy the token (looks like: 123456789:ABCxyz...)")
|
|
167
|
+
console.print()
|
|
168
|
+
|
|
169
|
+
default_token = existing_token or ""
|
|
170
|
+
bot_token = Prompt.ask(" Bot token", default=default_token)
|
|
171
|
+
|
|
172
|
+
# Admin user ID
|
|
173
|
+
console.print()
|
|
174
|
+
console.print(" [bold]Step 3b: Your Telegram User ID (Admin)[/bold]")
|
|
175
|
+
console.print(" 1. Open Telegram, search @userinfobot")
|
|
176
|
+
console.print(" 2. Send /start")
|
|
177
|
+
console.print(" 3. Copy your ID (a number like: 123456789)")
|
|
178
|
+
console.print()
|
|
179
|
+
|
|
180
|
+
default_admin = str(existing_admin) if existing_admin else ""
|
|
181
|
+
admin_id = Prompt.ask(" Your user ID (admin)", default=default_admin)
|
|
182
|
+
|
|
183
|
+
# Additional users
|
|
184
|
+
console.print()
|
|
185
|
+
console.print(" [bold]Step 3c: Additional Users (optional)[/bold]")
|
|
186
|
+
console.print(" [dim]Add friends who can use your bot. You can also add them later with /approve[/dim]")
|
|
187
|
+
console.print()
|
|
188
|
+
|
|
189
|
+
if existing_users:
|
|
190
|
+
console.print(f" [dim]Existing users: {existing_users}[/dim]")
|
|
191
|
+
|
|
192
|
+
add_users = Prompt.ask(" Additional user IDs (comma-separated, or Enter to skip)", default="")
|
|
193
|
+
if add_users.strip():
|
|
194
|
+
for uid in add_users.split(","):
|
|
195
|
+
uid = uid.strip()
|
|
196
|
+
if uid.isdigit():
|
|
197
|
+
allowed_users.append(int(uid))
|
|
198
|
+
|
|
199
|
+
# Step 4: Configuration
|
|
200
|
+
console.print("\n[bold]Step 4/5: Creating configuration...[/bold]")
|
|
201
|
+
|
|
202
|
+
telegram_config = {}
|
|
203
|
+
if bot_token:
|
|
204
|
+
telegram_config["bot_token"] = bot_token
|
|
205
|
+
if admin_id and admin_id.isdigit():
|
|
206
|
+
telegram_config["admins"] = [int(admin_id)]
|
|
207
|
+
if allowed_users:
|
|
208
|
+
telegram_config["allowed_users"] = allowed_users
|
|
209
|
+
|
|
210
|
+
config = {
|
|
211
|
+
"telegram": telegram_config,
|
|
212
|
+
"agent": {
|
|
213
|
+
"model": "opus",
|
|
214
|
+
"timeout": 1800,
|
|
215
|
+
"max_errors": 3,
|
|
216
|
+
},
|
|
217
|
+
"reliability": {
|
|
218
|
+
"plan": "max_200",
|
|
219
|
+
"exit_detection": {
|
|
220
|
+
"enabled": True,
|
|
221
|
+
"max_cycles": 100,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
"paths": {
|
|
225
|
+
"projects_dir": "./projects",
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
config_path = Path.cwd() / "config.yaml"
|
|
230
|
+
with open(config_path, "w") as f:
|
|
231
|
+
f.write("# Nullabot Configuration\n")
|
|
232
|
+
f.write("# Generated by: nullabot setup\n\n")
|
|
233
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
234
|
+
|
|
235
|
+
console.print(f" [green]â[/green] Created config.yaml")
|
|
236
|
+
|
|
237
|
+
# Create projects directory
|
|
238
|
+
projects_dir = Path.cwd() / "projects"
|
|
239
|
+
projects_dir.mkdir(exist_ok=True)
|
|
240
|
+
console.print(f" [green]â[/green] Created projects/ directory")
|
|
241
|
+
|
|
242
|
+
# Step 5: Done!
|
|
243
|
+
console.print("\n[bold]Step 5/5: Setup complete![/bold]")
|
|
244
|
+
console.print()
|
|
245
|
+
|
|
246
|
+
telegram_info = ""
|
|
247
|
+
if bot_token:
|
|
248
|
+
telegram_info = (
|
|
249
|
+
"\n[bold]Telegram Bot:[/bold]\n"
|
|
250
|
+
" nullabot bot Start the bot\n"
|
|
251
|
+
)
|
|
252
|
+
if allowed_users:
|
|
253
|
+
telegram_info += f" Users: admin + {len(allowed_users)} allowed\n"
|
|
254
|
+
telegram_info += " /approve <id> Add users later\n"
|
|
255
|
+
|
|
256
|
+
console.print(Panel(
|
|
257
|
+
"[bold green]Setup complete![/bold green]\n\n"
|
|
258
|
+
"[bold]Quick start:[/bold]\n"
|
|
259
|
+
" nullabot new myproject Create a project\n"
|
|
260
|
+
" nullabot think myproject \"task\" Start thinking\n"
|
|
261
|
+
+ telegram_info +
|
|
262
|
+
"\n[bold]Useful commands:[/bold]\n"
|
|
263
|
+
" nullabot projects List projects\n"
|
|
264
|
+
" nullabot status Show running agents\n"
|
|
265
|
+
" nullabot --help All commands",
|
|
266
|
+
title="â
Ready!",
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@cli.command("projects")
|
|
271
|
+
@click.pass_context
|
|
272
|
+
def list_projects(ctx):
|
|
273
|
+
"""List all projects."""
|
|
274
|
+
projects_dir = ctx.obj["projects_dir"]
|
|
275
|
+
projects = list_all_projects(projects_dir)
|
|
276
|
+
|
|
277
|
+
if not projects:
|
|
278
|
+
console.print("[yellow]No projects yet.[/yellow]")
|
|
279
|
+
console.print("Create one with: [bold]nullabot new <name>[/bold]")
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
table = Table(title="Projects")
|
|
283
|
+
table.add_column("Name", style="cyan")
|
|
284
|
+
table.add_column("Status", style="green")
|
|
285
|
+
table.add_column("Cycles")
|
|
286
|
+
table.add_column("Task")
|
|
287
|
+
|
|
288
|
+
import json
|
|
289
|
+
for p in sorted(projects, key=lambda x: x.name):
|
|
290
|
+
state_file = p / ".nullabot" / "state.json"
|
|
291
|
+
if state_file.exists():
|
|
292
|
+
try:
|
|
293
|
+
state = json.loads(state_file.read_text())
|
|
294
|
+
status = state.get("status", "unknown")
|
|
295
|
+
cycles = str(state.get("cycles", 0))
|
|
296
|
+
task = state.get("task", "-")
|
|
297
|
+
if task and len(task) > 40:
|
|
298
|
+
task = task[:40] + "..."
|
|
299
|
+
|
|
300
|
+
status_icon = {
|
|
301
|
+
"running": "đĸ Running",
|
|
302
|
+
"paused": "â¸ī¸ Paused",
|
|
303
|
+
"completed": "â
Done",
|
|
304
|
+
"idle": "âĒ Idle",
|
|
305
|
+
}.get(status, f"â {status}")
|
|
306
|
+
except:
|
|
307
|
+
status_icon = "â Unknown"
|
|
308
|
+
cycles = "-"
|
|
309
|
+
task = "-"
|
|
310
|
+
else:
|
|
311
|
+
status_icon = "âĒ Idle"
|
|
312
|
+
cycles = "0"
|
|
313
|
+
task = "-"
|
|
314
|
+
|
|
315
|
+
table.add_row(p.name, status_icon, cycles, task)
|
|
316
|
+
|
|
317
|
+
console.print(table)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@cli.command("new")
|
|
321
|
+
@click.argument("name")
|
|
322
|
+
@click.option("--description", "-d", default="", help="Project description")
|
|
323
|
+
@click.pass_context
|
|
324
|
+
def new_project(ctx, name: str, description: str):
|
|
325
|
+
"""Create a new project."""
|
|
326
|
+
projects_dir = ctx.obj["projects_dir"]
|
|
327
|
+
project_path = projects_dir / name
|
|
328
|
+
|
|
329
|
+
if project_path.exists():
|
|
330
|
+
console.print(f"[red]Project already exists:[/red] {name}")
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
|
|
333
|
+
# Create project structure
|
|
334
|
+
project_path.mkdir(parents=True)
|
|
335
|
+
(project_path / ".nullabot").mkdir()
|
|
336
|
+
|
|
337
|
+
# Save config
|
|
338
|
+
import json
|
|
339
|
+
config = {
|
|
340
|
+
"name": name,
|
|
341
|
+
"description": description,
|
|
342
|
+
"created_at": __import__("datetime").datetime.now().isoformat(),
|
|
343
|
+
}
|
|
344
|
+
(project_path / ".nullabot" / "config.json").write_text(json.dumps(config, indent=2))
|
|
345
|
+
|
|
346
|
+
console.print(f"[green]â
Created project:[/green] {name}")
|
|
347
|
+
console.print(f"[dim]Location: {project_path}[/dim]")
|
|
348
|
+
console.print()
|
|
349
|
+
console.print("Start an agent:")
|
|
350
|
+
console.print(f' [bold]nullabot think {name} "your research task"[/bold]')
|
|
351
|
+
console.print(f' [bold]nullabot design {name} "your design task"[/bold]')
|
|
352
|
+
console.print(f' [bold]nullabot code {name} "your coding task"[/bold]')
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@cli.command("status")
|
|
356
|
+
@click.argument("project", required=False)
|
|
357
|
+
@click.pass_context
|
|
358
|
+
def show_status(ctx, project: str | None):
|
|
359
|
+
"""Show status of project(s)."""
|
|
360
|
+
import json
|
|
361
|
+
projects_dir = ctx.obj["projects_dir"]
|
|
362
|
+
|
|
363
|
+
if project:
|
|
364
|
+
# Show specific project
|
|
365
|
+
project_path = projects_dir / project
|
|
366
|
+
if not project_path.exists():
|
|
367
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
|
|
370
|
+
state_file = project_path / ".nullabot" / "state.json"
|
|
371
|
+
if not state_file.exists():
|
|
372
|
+
console.print(f"[yellow]No agent has run on {project} yet[/yellow]")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
state = json.loads(state_file.read_text())
|
|
376
|
+
console.print(Panel(
|
|
377
|
+
f"[bold]Task:[/bold] {state.get('task', 'None')}\n"
|
|
378
|
+
f"[bold]Status:[/bold] {state.get('status', 'unknown')}\n"
|
|
379
|
+
f"[bold]Cycles:[/bold] {state.get('cycles', 0)}\n"
|
|
380
|
+
f"[bold]Started:[/bold] {state.get('started_at', 'N/A')}\n"
|
|
381
|
+
f"[bold]Updated:[/bold] {state.get('updated_at', 'N/A')}\n\n"
|
|
382
|
+
f"[bold]Last checkpoint:[/bold]\n{(state.get('last_checkpoint') or 'None')[:500]}",
|
|
383
|
+
title=f"đ {project}",
|
|
384
|
+
))
|
|
385
|
+
|
|
386
|
+
# Show files created
|
|
387
|
+
workspace_files = list(project_path.glob("**/*"))
|
|
388
|
+
workspace_files = [f for f in workspace_files if f.is_file() and ".nullabot" not in str(f)]
|
|
389
|
+
if workspace_files:
|
|
390
|
+
console.print("\n[bold]Files created:[/bold]")
|
|
391
|
+
for f in workspace_files[:20]:
|
|
392
|
+
rel = f.relative_to(project_path)
|
|
393
|
+
console.print(f" {rel}")
|
|
394
|
+
if len(workspace_files) > 20:
|
|
395
|
+
console.print(f" ... and {len(workspace_files) - 20} more")
|
|
396
|
+
else:
|
|
397
|
+
# Show all running projects
|
|
398
|
+
projects = list_all_projects(projects_dir)
|
|
399
|
+
running = []
|
|
400
|
+
|
|
401
|
+
for p in projects:
|
|
402
|
+
state_file = p / ".nullabot" / "state.json"
|
|
403
|
+
if state_file.exists():
|
|
404
|
+
state = json.loads(state_file.read_text())
|
|
405
|
+
if state.get("status") == "running":
|
|
406
|
+
running.append((p.name, state))
|
|
407
|
+
|
|
408
|
+
if not running:
|
|
409
|
+
console.print("[yellow]No agents currently running.[/yellow]")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
for name, state in running:
|
|
413
|
+
console.print(Panel(
|
|
414
|
+
f"[bold]Task:[/bold] {state.get('task', 'None')}\n"
|
|
415
|
+
f"[bold]Cycles:[/bold] {state.get('cycles', 0)}",
|
|
416
|
+
title=f"đĸ {name}",
|
|
417
|
+
border_style="green",
|
|
418
|
+
))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def run_agent(
|
|
422
|
+
project_path: Path,
|
|
423
|
+
task: str,
|
|
424
|
+
agent_type: str,
|
|
425
|
+
model: str,
|
|
426
|
+
continuous: bool,
|
|
427
|
+
):
|
|
428
|
+
"""Run an agent on a project."""
|
|
429
|
+
from nullabot.agents.claude_agent import ClaudeAgent
|
|
430
|
+
|
|
431
|
+
agent = ClaudeAgent(
|
|
432
|
+
workspace=project_path,
|
|
433
|
+
agent_type=agent_type,
|
|
434
|
+
model=model,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
await agent.start(task, continuous=continuous)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@cli.command("think")
|
|
441
|
+
@click.argument("project")
|
|
442
|
+
@click.argument("task")
|
|
443
|
+
@click.option("--model", "-m", default="opus", help="Model: opus, sonnet, haiku")
|
|
444
|
+
@click.option("--once", is_flag=True, help="Run single cycle only")
|
|
445
|
+
@click.pass_context
|
|
446
|
+
def start_thinker(ctx, project: str, task: str, model: str, once: bool):
|
|
447
|
+
"""Start Thinker agent (research & ideation)."""
|
|
448
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
449
|
+
|
|
450
|
+
if not project_path.exists():
|
|
451
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
452
|
+
console.print(f"Create it with: [bold]nullabot new {project}[/bold]")
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
asyncio.run(run_agent(project_path, task, "thinker", model, not once))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@cli.command("design")
|
|
459
|
+
@click.argument("project")
|
|
460
|
+
@click.argument("task")
|
|
461
|
+
@click.option("--model", "-m", default="opus", help="Model: opus, sonnet, haiku")
|
|
462
|
+
@click.option("--once", is_flag=True, help="Run single cycle only")
|
|
463
|
+
@click.pass_context
|
|
464
|
+
def start_designer(ctx, project: str, task: str, model: str, once: bool):
|
|
465
|
+
"""Start Designer agent (UI/UX specs)."""
|
|
466
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
467
|
+
|
|
468
|
+
if not project_path.exists():
|
|
469
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
470
|
+
sys.exit(1)
|
|
471
|
+
|
|
472
|
+
asyncio.run(run_agent(project_path, task, "designer", model, not once))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@cli.command("code")
|
|
476
|
+
@click.argument("project")
|
|
477
|
+
@click.argument("task")
|
|
478
|
+
@click.option("--model", "-m", default="opus", help="Model: opus, sonnet, haiku")
|
|
479
|
+
@click.option("--once", is_flag=True, help="Run single cycle only")
|
|
480
|
+
@click.pass_context
|
|
481
|
+
def start_coder(ctx, project: str, task: str, model: str, once: bool):
|
|
482
|
+
"""Start Coder agent (write code & tests)."""
|
|
483
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
484
|
+
|
|
485
|
+
if not project_path.exists():
|
|
486
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
|
|
489
|
+
asyncio.run(run_agent(project_path, task, "coder", model, not once))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@cli.command("run")
|
|
493
|
+
@click.argument("project")
|
|
494
|
+
@click.argument("task")
|
|
495
|
+
@click.option("--type", "-t", "agent_type", default="thinker", help="Agent type")
|
|
496
|
+
@click.option("--model", "-m", default="opus", help="Model: opus, sonnet, haiku")
|
|
497
|
+
@click.pass_context
|
|
498
|
+
def run_once(ctx, project: str, task: str, agent_type: str, model: str):
|
|
499
|
+
"""Run a single cycle (no continuous loop)."""
|
|
500
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
501
|
+
|
|
502
|
+
if not project_path.exists():
|
|
503
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
|
|
506
|
+
asyncio.run(run_agent(project_path, task, agent_type, model, continuous=False))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@cli.command("stop")
|
|
510
|
+
@click.argument("project")
|
|
511
|
+
@click.pass_context
|
|
512
|
+
def stop_agent(ctx, project: str):
|
|
513
|
+
"""Mark agent as stopped (updates state file)."""
|
|
514
|
+
import json
|
|
515
|
+
|
|
516
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
517
|
+
|
|
518
|
+
if not project_path.exists():
|
|
519
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
520
|
+
sys.exit(1)
|
|
521
|
+
|
|
522
|
+
state_file = project_path / ".nullabot" / "state.json"
|
|
523
|
+
if state_file.exists():
|
|
524
|
+
state = json.loads(state_file.read_text())
|
|
525
|
+
state["status"] = "paused"
|
|
526
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
527
|
+
console.print(f"[green]Marked {project} as paused[/green]")
|
|
528
|
+
else:
|
|
529
|
+
console.print(f"[yellow]No agent state found for {project}[/yellow]")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@cli.command("delete")
|
|
533
|
+
@click.argument("project")
|
|
534
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
535
|
+
@click.pass_context
|
|
536
|
+
def delete_project(ctx, project: str, yes: bool):
|
|
537
|
+
"""Delete a project and all its files."""
|
|
538
|
+
import shutil
|
|
539
|
+
|
|
540
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
541
|
+
|
|
542
|
+
if not project_path.exists():
|
|
543
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
|
|
546
|
+
if not yes:
|
|
547
|
+
confirm = console.input(f"[red]Delete '{project}' and all files?[/red] [y/N]: ")
|
|
548
|
+
if confirm.lower() != "y":
|
|
549
|
+
console.print("Cancelled.")
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
shutil.rmtree(project_path)
|
|
553
|
+
console.print(f"[green]Deleted project:[/green] {project}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@cli.command("logs")
|
|
557
|
+
@click.argument("project")
|
|
558
|
+
@click.option("--lines", "-n", default=20, help="Number of lines to show")
|
|
559
|
+
@click.pass_context
|
|
560
|
+
def show_logs(ctx, project: str, lines: int):
|
|
561
|
+
"""Show agent logs for a project."""
|
|
562
|
+
project_path = ctx.obj["projects_dir"] / project
|
|
563
|
+
|
|
564
|
+
if not project_path.exists():
|
|
565
|
+
console.print(f"[red]Project not found:[/red] {project}")
|
|
566
|
+
sys.exit(1)
|
|
567
|
+
|
|
568
|
+
log_file = project_path / ".nullabot" / "log.jsonl"
|
|
569
|
+
if not log_file.exists():
|
|
570
|
+
console.print("[yellow]No logs yet[/yellow]")
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
import json
|
|
574
|
+
log_lines = log_file.read_text().strip().split("\n")
|
|
575
|
+
|
|
576
|
+
for line in log_lines[-lines:]:
|
|
577
|
+
try:
|
|
578
|
+
entry = json.loads(line)
|
|
579
|
+
ts = entry.get("timestamp", "")[:19]
|
|
580
|
+
event = entry.get("event", "")
|
|
581
|
+
data = entry.get("data", {})
|
|
582
|
+
|
|
583
|
+
if event == "started":
|
|
584
|
+
console.print(f"[dim]{ts}[/dim] [green]Started:[/green] {data.get('task', '')[:60]}")
|
|
585
|
+
elif event == "stopped":
|
|
586
|
+
console.print(f"[dim]{ts}[/dim] [yellow]Stopped:[/yellow] {data.get('cycles', 0)} cycles")
|
|
587
|
+
elif event == "error":
|
|
588
|
+
console.print(f"[dim]{ts}[/dim] [red]Error:[/red] {data.get('message', '')[:60]}")
|
|
589
|
+
elif event == "response":
|
|
590
|
+
content = data.get("content", "")[:80]
|
|
591
|
+
console.print(f"[dim]{ts}[/dim] [blue]Response:[/blue] {content}...")
|
|
592
|
+
except:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@cli.command("usage")
|
|
597
|
+
@click.option("--watch", "-w", is_flag=True, help="Watch mode - refresh every 5 seconds")
|
|
598
|
+
@click.pass_context
|
|
599
|
+
def show_usage(ctx, watch: bool):
|
|
600
|
+
"""Show nullabot project usage and costs."""
|
|
601
|
+
import time
|
|
602
|
+
from nullabot.core.memory import UsageTracker
|
|
603
|
+
|
|
604
|
+
projects_dir = ctx.obj["projects_dir"]
|
|
605
|
+
base_dir = projects_dir.parent
|
|
606
|
+
|
|
607
|
+
def render():
|
|
608
|
+
# Per-project breakdown
|
|
609
|
+
projects = list_all_projects(projects_dir)
|
|
610
|
+
if projects:
|
|
611
|
+
table = Table(title="Nullabot Project Usage", show_header=True)
|
|
612
|
+
table.add_column("Project", style="cyan")
|
|
613
|
+
table.add_column("Cycles", justify="right")
|
|
614
|
+
table.add_column("Hours", justify="right")
|
|
615
|
+
table.add_column("Cost", justify="right", style="green")
|
|
616
|
+
|
|
617
|
+
total_cost = 0
|
|
618
|
+
total_cycles = 0
|
|
619
|
+
total_hours = 0
|
|
620
|
+
|
|
621
|
+
for p in sorted(projects, key=lambda x: x.name):
|
|
622
|
+
try:
|
|
623
|
+
tracker = UsageTracker(p, base_dir)
|
|
624
|
+
summary = tracker.get_summary()
|
|
625
|
+
if summary["total_cycles"] > 0:
|
|
626
|
+
table.add_row(
|
|
627
|
+
p.name,
|
|
628
|
+
str(summary["total_cycles"]),
|
|
629
|
+
f"{summary['total_hours']:.2f}",
|
|
630
|
+
f"${summary['total_cost_usd']:.2f}",
|
|
631
|
+
)
|
|
632
|
+
total_cost += summary["total_cost_usd"]
|
|
633
|
+
total_cycles += summary["total_cycles"]
|
|
634
|
+
total_hours += summary["total_hours"]
|
|
635
|
+
except:
|
|
636
|
+
pass
|
|
637
|
+
|
|
638
|
+
if total_cycles > 0:
|
|
639
|
+
table.add_section()
|
|
640
|
+
table.add_row(
|
|
641
|
+
"[bold]Total[/bold]",
|
|
642
|
+
f"[bold]{total_cycles}[/bold]",
|
|
643
|
+
f"[bold]{total_hours:.2f}[/bold]",
|
|
644
|
+
f"[bold]${total_cost:.2f}[/bold]",
|
|
645
|
+
)
|
|
646
|
+
console.print(table)
|
|
647
|
+
else:
|
|
648
|
+
console.print("[dim]No nullabot usage yet[/dim]")
|
|
649
|
+
else:
|
|
650
|
+
console.print("[dim]No projects yet[/dim]")
|
|
651
|
+
|
|
652
|
+
# Note about real usage
|
|
653
|
+
console.print()
|
|
654
|
+
console.print(Panel(
|
|
655
|
+
"[bold]For real Claude Code limits:[/bold]\n\n"
|
|
656
|
+
"Run [cyan]claude[/cyan] in terminal, then type [cyan]/usage[/cyan]\n\n"
|
|
657
|
+
"[dim]Shows actual 5hr session & weekly limits from Anthropic[/dim]",
|
|
658
|
+
title="âšī¸ Claude Code CLI Usage",
|
|
659
|
+
border_style="blue",
|
|
660
|
+
))
|
|
661
|
+
|
|
662
|
+
if watch:
|
|
663
|
+
console.print("[dim]Watching usage... Press Ctrl+C to stop[/dim]\n")
|
|
664
|
+
try:
|
|
665
|
+
while True:
|
|
666
|
+
console.clear()
|
|
667
|
+
render()
|
|
668
|
+
console.print(f"\n[dim]Last updated: {datetime.now().strftime('%H:%M:%S')} (refreshing every 5s)[/dim]")
|
|
669
|
+
time.sleep(5)
|
|
670
|
+
except KeyboardInterrupt:
|
|
671
|
+
console.print("\n[yellow]Stopped watching[/yellow]")
|
|
672
|
+
else:
|
|
673
|
+
render()
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@cli.command("bot")
|
|
677
|
+
@click.option("--token", envvar="TELEGRAM_BOT_TOKEN", help="Telegram bot token")
|
|
678
|
+
@click.pass_context
|
|
679
|
+
def start_bot(ctx, token: str):
|
|
680
|
+
"""Start Telegram bot for remote control."""
|
|
681
|
+
# Load config
|
|
682
|
+
config_path = Path.cwd() / "config.yaml"
|
|
683
|
+
allowed_users = []
|
|
684
|
+
admins = []
|
|
685
|
+
config_token = None
|
|
686
|
+
|
|
687
|
+
if config_path.exists():
|
|
688
|
+
try:
|
|
689
|
+
config = yaml.safe_load(config_path.read_text())
|
|
690
|
+
telegram_config = config.get("telegram", {})
|
|
691
|
+
config_token = telegram_config.get("bot_token")
|
|
692
|
+
allowed_users = telegram_config.get("allowed_users", [])
|
|
693
|
+
admins = telegram_config.get("admins", [])
|
|
694
|
+
# Fallback: treat user_id as admin if no admins configured
|
|
695
|
+
if not admins and telegram_config.get("user_id"):
|
|
696
|
+
admins = [telegram_config.get("user_id")]
|
|
697
|
+
except:
|
|
698
|
+
pass
|
|
699
|
+
|
|
700
|
+
# Use token from: CLI arg > env var > config
|
|
701
|
+
token = token or config_token
|
|
702
|
+
if not token:
|
|
703
|
+
console.print("[red]â No bot token found[/red]")
|
|
704
|
+
console.print("Run: nullabot setup")
|
|
705
|
+
console.print("Or set: export TELEGRAM_BOT_TOKEN=your-token")
|
|
706
|
+
sys.exit(1)
|
|
707
|
+
|
|
708
|
+
projects_dir = ctx.obj["projects_dir"]
|
|
709
|
+
|
|
710
|
+
console.print(Panel(
|
|
711
|
+
f"[bold green]Starting Nullabot Telegram Bot[/bold green]\n\n"
|
|
712
|
+
f"Projects dir: {projects_dir}\n"
|
|
713
|
+
f"Admins: {admins or 'none'}\n"
|
|
714
|
+
f"Allowed users: {allowed_users or 'all'}\n\n"
|
|
715
|
+
f"[bold]Features:[/bold]\n"
|
|
716
|
+
f" đ° Cost tracking enabled\n"
|
|
717
|
+
f" đ§ Memory system enabled\n"
|
|
718
|
+
f" đ Agent handoff enabled\n"
|
|
719
|
+
f" âą 30 min timeout",
|
|
720
|
+
title="đ¤ Nullabot Bot",
|
|
721
|
+
))
|
|
722
|
+
|
|
723
|
+
from nullabot.bot.telegram import TelegramBot
|
|
724
|
+
|
|
725
|
+
bot = TelegramBot(
|
|
726
|
+
token=token,
|
|
727
|
+
projects_dir=projects_dir,
|
|
728
|
+
allowed_users=allowed_users,
|
|
729
|
+
admins=admins,
|
|
730
|
+
)
|
|
731
|
+
bot.run()
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def main():
|
|
735
|
+
"""Entry point."""
|
|
736
|
+
cli(obj={})
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
if __name__ == "__main__":
|
|
740
|
+
main()
|