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/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()