ragnarbot-ai 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 (56) hide show
  1. ragnarbot/__init__.py +6 -0
  2. ragnarbot/__main__.py +8 -0
  3. ragnarbot/agent/__init__.py +8 -0
  4. ragnarbot/agent/context.py +223 -0
  5. ragnarbot/agent/loop.py +365 -0
  6. ragnarbot/agent/memory.py +109 -0
  7. ragnarbot/agent/skills.py +228 -0
  8. ragnarbot/agent/subagent.py +241 -0
  9. ragnarbot/agent/tools/__init__.py +6 -0
  10. ragnarbot/agent/tools/base.py +102 -0
  11. ragnarbot/agent/tools/cron.py +114 -0
  12. ragnarbot/agent/tools/filesystem.py +191 -0
  13. ragnarbot/agent/tools/message.py +86 -0
  14. ragnarbot/agent/tools/registry.py +73 -0
  15. ragnarbot/agent/tools/shell.py +141 -0
  16. ragnarbot/agent/tools/spawn.py +65 -0
  17. ragnarbot/agent/tools/web.py +163 -0
  18. ragnarbot/bus/__init__.py +6 -0
  19. ragnarbot/bus/events.py +37 -0
  20. ragnarbot/bus/queue.py +81 -0
  21. ragnarbot/channels/__init__.py +6 -0
  22. ragnarbot/channels/base.py +121 -0
  23. ragnarbot/channels/manager.py +129 -0
  24. ragnarbot/channels/telegram.py +302 -0
  25. ragnarbot/cli/__init__.py +1 -0
  26. ragnarbot/cli/commands.py +568 -0
  27. ragnarbot/config/__init__.py +6 -0
  28. ragnarbot/config/loader.py +95 -0
  29. ragnarbot/config/schema.py +114 -0
  30. ragnarbot/cron/__init__.py +6 -0
  31. ragnarbot/cron/service.py +346 -0
  32. ragnarbot/cron/types.py +59 -0
  33. ragnarbot/heartbeat/__init__.py +5 -0
  34. ragnarbot/heartbeat/service.py +130 -0
  35. ragnarbot/providers/__init__.py +6 -0
  36. ragnarbot/providers/base.py +69 -0
  37. ragnarbot/providers/litellm_provider.py +135 -0
  38. ragnarbot/providers/transcription.py +67 -0
  39. ragnarbot/session/__init__.py +5 -0
  40. ragnarbot/session/manager.py +202 -0
  41. ragnarbot/skills/README.md +24 -0
  42. ragnarbot/skills/cron/SKILL.md +40 -0
  43. ragnarbot/skills/github/SKILL.md +48 -0
  44. ragnarbot/skills/skill-creator/SKILL.md +371 -0
  45. ragnarbot/skills/summarize/SKILL.md +67 -0
  46. ragnarbot/skills/tmux/SKILL.md +121 -0
  47. ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  48. ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  49. ragnarbot/skills/weather/SKILL.md +49 -0
  50. ragnarbot/utils/__init__.py +5 -0
  51. ragnarbot/utils/helpers.py +91 -0
  52. ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
  53. ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
  54. ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
  55. ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
  56. ragnarbot_ai-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,568 @@
1
+ """CLI commands for ragnarbot."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from ragnarbot import __version__, __logo__
11
+
12
+ app = typer.Typer(
13
+ name="ragnarbot",
14
+ help=f"{__logo__} ragnarbot - Personal AI Assistant",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ console = Console()
19
+
20
+
21
+ def version_callback(value: bool):
22
+ if value:
23
+ console.print(f"{__logo__} ragnarbot v{__version__}")
24
+ raise typer.Exit()
25
+
26
+
27
+ @app.callback()
28
+ def main(
29
+ version: bool = typer.Option(
30
+ None, "--version", "-v", callback=version_callback, is_eager=True
31
+ ),
32
+ ):
33
+ """ragnarbot - Personal AI Assistant."""
34
+ pass
35
+
36
+
37
+ # ============================================================================
38
+ # Onboard / Setup
39
+ # ============================================================================
40
+
41
+
42
+ @app.command()
43
+ def onboard():
44
+ """Initialize ragnarbot configuration and workspace."""
45
+ from ragnarbot.config.loader import get_config_path, save_config
46
+ from ragnarbot.config.schema import Config
47
+ from ragnarbot.utils.helpers import get_workspace_path
48
+
49
+ config_path = get_config_path()
50
+
51
+ if config_path.exists():
52
+ console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
53
+ if not typer.confirm("Overwrite?"):
54
+ raise typer.Exit()
55
+
56
+ # Create default config
57
+ config = Config()
58
+ save_config(config)
59
+ console.print(f"[green]✓[/green] Created config at {config_path}")
60
+
61
+ # Create workspace
62
+ workspace = get_workspace_path()
63
+ console.print(f"[green]✓[/green] Created workspace at {workspace}")
64
+
65
+ # Create default bootstrap files
66
+ _create_workspace_templates(workspace)
67
+
68
+ console.print(f"\n{__logo__} ragnarbot is ready!")
69
+ console.print("\nNext steps:")
70
+ console.print(" 1. Add your API key to [cyan]~/.ragnarbot/config.json[/cyan]")
71
+ console.print(" Get one at: https://console.anthropic.com/keys")
72
+ console.print(" 2. Chat: [cyan]ragnarbot agent -m \"Hello!\"[/cyan]")
73
+ console.print("\n[dim]Want Telegram? See: https://github.com/BlckLvls/ragnarbot#-chat-apps[/dim]")
74
+
75
+
76
+
77
+
78
+ def _create_workspace_templates(workspace: Path):
79
+ """Create default workspace template files."""
80
+ templates = {
81
+ "AGENTS.md": """# Agent Instructions
82
+
83
+ You are a helpful AI assistant. Be concise, accurate, and friendly.
84
+
85
+ ## Guidelines
86
+
87
+ - Always explain what you're doing before taking actions
88
+ - Ask for clarification when the request is ambiguous
89
+ - Use tools to help accomplish tasks
90
+ - Remember important information in your memory files
91
+ """,
92
+ "SOUL.md": """# Soul
93
+
94
+ I am ragnarbot, a lightweight AI assistant.
95
+
96
+ ## Personality
97
+
98
+ - Helpful and friendly
99
+ - Concise and to the point
100
+ - Curious and eager to learn
101
+
102
+ ## Values
103
+
104
+ - Accuracy over speed
105
+ - User privacy and safety
106
+ - Transparency in actions
107
+ """,
108
+ "USER.md": """# User
109
+
110
+ Information about the user goes here.
111
+
112
+ ## Preferences
113
+
114
+ - Communication style: (casual/formal)
115
+ - Timezone: (your timezone)
116
+ - Language: (your preferred language)
117
+ """,
118
+ }
119
+
120
+ for filename, content in templates.items():
121
+ file_path = workspace / filename
122
+ if not file_path.exists():
123
+ file_path.write_text(content)
124
+ console.print(f" [dim]Created {filename}[/dim]")
125
+
126
+ # Create memory directory and MEMORY.md
127
+ memory_dir = workspace / "memory"
128
+ memory_dir.mkdir(exist_ok=True)
129
+ memory_file = memory_dir / "MEMORY.md"
130
+ if not memory_file.exists():
131
+ memory_file.write_text("""# Long-term Memory
132
+
133
+ This file stores important information that should persist across sessions.
134
+
135
+ ## User Information
136
+
137
+ (Important facts about the user)
138
+
139
+ ## Preferences
140
+
141
+ (User preferences learned over time)
142
+
143
+ ## Important Notes
144
+
145
+ (Things to remember)
146
+ """)
147
+ console.print(" [dim]Created memory/MEMORY.md[/dim]")
148
+
149
+
150
+ # ============================================================================
151
+ # Gateway / Server
152
+ # ============================================================================
153
+
154
+
155
+ @app.command()
156
+ def gateway(
157
+ port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
158
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
159
+ ):
160
+ """Start the ragnarbot gateway."""
161
+ from ragnarbot.config.loader import load_config, get_data_dir
162
+ from ragnarbot.bus.queue import MessageBus
163
+ from ragnarbot.providers.litellm_provider import LiteLLMProvider
164
+ from ragnarbot.agent.loop import AgentLoop
165
+ from ragnarbot.channels.manager import ChannelManager
166
+ from ragnarbot.cron.service import CronService
167
+ from ragnarbot.cron.types import CronJob
168
+ from ragnarbot.heartbeat.service import HeartbeatService
169
+
170
+ if verbose:
171
+ import logging
172
+ logging.basicConfig(level=logging.DEBUG)
173
+
174
+ console.print(f"{__logo__} Starting ragnarbot gateway on port {port}...")
175
+
176
+ config = load_config()
177
+
178
+ # Create components
179
+ bus = MessageBus()
180
+
181
+ # Create provider
182
+ api_key = config.get_api_key()
183
+ api_base = config.get_api_base()
184
+
185
+ if not api_key:
186
+ console.print("[red]Error: No API key configured.[/red]")
187
+ console.print("Set one in ~/.ragnarbot/config.json under providers.anthropic.apiKey")
188
+ raise typer.Exit(1)
189
+
190
+ provider = LiteLLMProvider(
191
+ api_key=api_key,
192
+ api_base=api_base,
193
+ default_model=config.agents.defaults.model
194
+ )
195
+
196
+ # Create cron service first (callback set after agent creation)
197
+ cron_store_path = get_data_dir() / "cron" / "jobs.json"
198
+ cron = CronService(cron_store_path)
199
+
200
+ # Create agent with cron service
201
+ agent = AgentLoop(
202
+ bus=bus,
203
+ provider=provider,
204
+ workspace=config.workspace_path,
205
+ model=config.agents.defaults.model,
206
+ max_iterations=config.agents.defaults.max_tool_iterations,
207
+ brave_api_key=config.tools.web.search.api_key or None,
208
+ exec_config=config.tools.exec,
209
+ cron_service=cron,
210
+ )
211
+
212
+ # Set cron callback (needs agent)
213
+ async def on_cron_job(job: CronJob) -> str | None:
214
+ """Execute a cron job through the agent."""
215
+ response = await agent.process_direct(
216
+ job.payload.message,
217
+ session_key=f"cron:{job.id}",
218
+ channel=job.payload.channel or "cli",
219
+ chat_id=job.payload.to or "direct",
220
+ )
221
+ if job.payload.deliver and job.payload.to:
222
+ from ragnarbot.bus.events import OutboundMessage
223
+ await bus.publish_outbound(OutboundMessage(
224
+ channel=job.payload.channel or "cli",
225
+ chat_id=job.payload.to,
226
+ content=response or ""
227
+ ))
228
+ return response
229
+ cron.on_job = on_cron_job
230
+
231
+ # Create heartbeat service
232
+ async def on_heartbeat(prompt: str) -> str:
233
+ """Execute heartbeat through the agent."""
234
+ return await agent.process_direct(prompt, session_key="heartbeat")
235
+
236
+ heartbeat = HeartbeatService(
237
+ workspace=config.workspace_path,
238
+ on_heartbeat=on_heartbeat,
239
+ interval_s=30 * 60, # 30 minutes
240
+ enabled=True
241
+ )
242
+
243
+ # Create channel manager
244
+ channels = ChannelManager(config, bus)
245
+
246
+ if channels.enabled_channels:
247
+ console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
248
+ else:
249
+ console.print("[yellow]Warning: No channels enabled[/yellow]")
250
+
251
+ cron_status = cron.status()
252
+ if cron_status["jobs"] > 0:
253
+ console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
254
+
255
+ console.print(f"[green]✓[/green] Heartbeat: every 30m")
256
+
257
+ async def run():
258
+ try:
259
+ await cron.start()
260
+ await heartbeat.start()
261
+ await asyncio.gather(
262
+ agent.run(),
263
+ channels.start_all(),
264
+ )
265
+ except KeyboardInterrupt:
266
+ console.print("\nShutting down...")
267
+ heartbeat.stop()
268
+ cron.stop()
269
+ agent.stop()
270
+ await channels.stop_all()
271
+
272
+ asyncio.run(run())
273
+
274
+
275
+
276
+
277
+ # ============================================================================
278
+ # Agent Commands
279
+ # ============================================================================
280
+
281
+
282
+ @app.command()
283
+ def agent(
284
+ message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
285
+ session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
286
+ ):
287
+ """Interact with the agent directly."""
288
+ from ragnarbot.config.loader import load_config
289
+ from ragnarbot.bus.queue import MessageBus
290
+ from ragnarbot.providers.litellm_provider import LiteLLMProvider
291
+ from ragnarbot.agent.loop import AgentLoop
292
+
293
+ config = load_config()
294
+
295
+ api_key = config.get_api_key()
296
+ api_base = config.get_api_base()
297
+
298
+ if not api_key:
299
+ console.print("[red]Error: No API key configured.[/red]")
300
+ raise typer.Exit(1)
301
+
302
+ bus = MessageBus()
303
+ provider = LiteLLMProvider(
304
+ api_key=api_key,
305
+ api_base=api_base,
306
+ default_model=config.agents.defaults.model
307
+ )
308
+
309
+ agent_loop = AgentLoop(
310
+ bus=bus,
311
+ provider=provider,
312
+ workspace=config.workspace_path,
313
+ brave_api_key=config.tools.web.search.api_key or None,
314
+ exec_config=config.tools.exec,
315
+ )
316
+
317
+ if message:
318
+ # Single message mode
319
+ async def run_once():
320
+ response = await agent_loop.process_direct(message, session_id)
321
+ console.print(f"\n{__logo__} {response}")
322
+
323
+ asyncio.run(run_once())
324
+ else:
325
+ # Interactive mode
326
+ console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
327
+
328
+ async def run_interactive():
329
+ while True:
330
+ try:
331
+ user_input = console.input("[bold blue]You:[/bold blue] ")
332
+ if not user_input.strip():
333
+ continue
334
+
335
+ response = await agent_loop.process_direct(user_input, session_id)
336
+ console.print(f"\n{__logo__} {response}\n")
337
+ except KeyboardInterrupt:
338
+ console.print("\nGoodbye!")
339
+ break
340
+
341
+ asyncio.run(run_interactive())
342
+
343
+
344
+ # ============================================================================
345
+ # Channel Commands
346
+ # ============================================================================
347
+
348
+
349
+ channels_app = typer.Typer(help="Manage channels")
350
+ app.add_typer(channels_app, name="channels")
351
+
352
+
353
+ @channels_app.command("status")
354
+ def channels_status():
355
+ """Show channel status."""
356
+ from ragnarbot.config.loader import load_config
357
+
358
+ config = load_config()
359
+
360
+ table = Table(title="Channel Status")
361
+ table.add_column("Channel", style="cyan")
362
+ table.add_column("Enabled", style="green")
363
+ table.add_column("Configuration", style="yellow")
364
+
365
+ # Telegram
366
+ tg = config.channels.telegram
367
+ tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
368
+ table.add_row(
369
+ "Telegram",
370
+ "✓" if tg.enabled else "✗",
371
+ tg_config
372
+ )
373
+
374
+ console.print(table)
375
+
376
+
377
+
378
+ # ============================================================================
379
+ # Cron Commands
380
+ # ============================================================================
381
+
382
+ cron_app = typer.Typer(help="Manage scheduled tasks")
383
+ app.add_typer(cron_app, name="cron")
384
+
385
+
386
+ @cron_app.command("list")
387
+ def cron_list(
388
+ all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
389
+ ):
390
+ """List scheduled jobs."""
391
+ from ragnarbot.config.loader import get_data_dir
392
+ from ragnarbot.cron.service import CronService
393
+
394
+ store_path = get_data_dir() / "cron" / "jobs.json"
395
+ service = CronService(store_path)
396
+
397
+ jobs = service.list_jobs(include_disabled=all)
398
+
399
+ if not jobs:
400
+ console.print("No scheduled jobs.")
401
+ return
402
+
403
+ table = Table(title="Scheduled Jobs")
404
+ table.add_column("ID", style="cyan")
405
+ table.add_column("Name")
406
+ table.add_column("Schedule")
407
+ table.add_column("Status")
408
+ table.add_column("Next Run")
409
+
410
+ import time
411
+ for job in jobs:
412
+ # Format schedule
413
+ if job.schedule.kind == "every":
414
+ sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
415
+ elif job.schedule.kind == "cron":
416
+ sched = job.schedule.expr or ""
417
+ else:
418
+ sched = "one-time"
419
+
420
+ # Format next run
421
+ next_run = ""
422
+ if job.state.next_run_at_ms:
423
+ next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
424
+ next_run = next_time
425
+
426
+ status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
427
+
428
+ table.add_row(job.id, job.name, sched, status, next_run)
429
+
430
+ console.print(table)
431
+
432
+
433
+ @cron_app.command("add")
434
+ def cron_add(
435
+ name: str = typer.Option(..., "--name", "-n", help="Job name"),
436
+ message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
437
+ every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
438
+ cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
439
+ at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
440
+ deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
441
+ to: str = typer.Option(None, "--to", help="Recipient for delivery"),
442
+ channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram')"),
443
+ ):
444
+ """Add a scheduled job."""
445
+ from ragnarbot.config.loader import get_data_dir
446
+ from ragnarbot.cron.service import CronService
447
+ from ragnarbot.cron.types import CronSchedule
448
+
449
+ # Determine schedule type
450
+ if every:
451
+ schedule = CronSchedule(kind="every", every_ms=every * 1000)
452
+ elif cron_expr:
453
+ schedule = CronSchedule(kind="cron", expr=cron_expr)
454
+ elif at:
455
+ import datetime
456
+ dt = datetime.datetime.fromisoformat(at)
457
+ schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
458
+ else:
459
+ console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
460
+ raise typer.Exit(1)
461
+
462
+ store_path = get_data_dir() / "cron" / "jobs.json"
463
+ service = CronService(store_path)
464
+
465
+ job = service.add_job(
466
+ name=name,
467
+ schedule=schedule,
468
+ message=message,
469
+ deliver=deliver,
470
+ to=to,
471
+ channel=channel,
472
+ )
473
+
474
+ console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
475
+
476
+
477
+ @cron_app.command("remove")
478
+ def cron_remove(
479
+ job_id: str = typer.Argument(..., help="Job ID to remove"),
480
+ ):
481
+ """Remove a scheduled job."""
482
+ from ragnarbot.config.loader import get_data_dir
483
+ from ragnarbot.cron.service import CronService
484
+
485
+ store_path = get_data_dir() / "cron" / "jobs.json"
486
+ service = CronService(store_path)
487
+
488
+ if service.remove_job(job_id):
489
+ console.print(f"[green]✓[/green] Removed job {job_id}")
490
+ else:
491
+ console.print(f"[red]Job {job_id} not found[/red]")
492
+
493
+
494
+ @cron_app.command("enable")
495
+ def cron_enable(
496
+ job_id: str = typer.Argument(..., help="Job ID"),
497
+ disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
498
+ ):
499
+ """Enable or disable a job."""
500
+ from ragnarbot.config.loader import get_data_dir
501
+ from ragnarbot.cron.service import CronService
502
+
503
+ store_path = get_data_dir() / "cron" / "jobs.json"
504
+ service = CronService(store_path)
505
+
506
+ job = service.enable_job(job_id, enabled=not disable)
507
+ if job:
508
+ status = "disabled" if disable else "enabled"
509
+ console.print(f"[green]✓[/green] Job '{job.name}' {status}")
510
+ else:
511
+ console.print(f"[red]Job {job_id} not found[/red]")
512
+
513
+
514
+ @cron_app.command("run")
515
+ def cron_run(
516
+ job_id: str = typer.Argument(..., help="Job ID to run"),
517
+ force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
518
+ ):
519
+ """Manually run a job."""
520
+ from ragnarbot.config.loader import get_data_dir
521
+ from ragnarbot.cron.service import CronService
522
+
523
+ store_path = get_data_dir() / "cron" / "jobs.json"
524
+ service = CronService(store_path)
525
+
526
+ async def run():
527
+ return await service.run_job(job_id, force=force)
528
+
529
+ if asyncio.run(run()):
530
+ console.print(f"[green]✓[/green] Job executed")
531
+ else:
532
+ console.print(f"[red]Failed to run job {job_id}[/red]")
533
+
534
+
535
+ # ============================================================================
536
+ # Status Commands
537
+ # ============================================================================
538
+
539
+
540
+ @app.command()
541
+ def status():
542
+ """Show ragnarbot status."""
543
+ from ragnarbot.config.loader import load_config, get_config_path
544
+
545
+ config_path = get_config_path()
546
+ config = load_config()
547
+ workspace = config.workspace_path
548
+
549
+ console.print(f"{__logo__} ragnarbot Status\n")
550
+
551
+ console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
552
+ console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
553
+
554
+ if config_path.exists():
555
+ console.print(f"Model: {config.agents.defaults.model}")
556
+
557
+ # Check API keys
558
+ has_anthropic = bool(config.providers.anthropic.api_key)
559
+ has_openai = bool(config.providers.openai.api_key)
560
+ has_gemini = bool(config.providers.gemini.api_key)
561
+
562
+ console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
563
+ console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
564
+ console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
565
+
566
+
567
+ if __name__ == "__main__":
568
+ app()
@@ -0,0 +1,6 @@
1
+ """Configuration module for ragnarbot."""
2
+
3
+ from ragnarbot.config.loader import load_config, get_config_path
4
+ from ragnarbot.config.schema import Config
5
+
6
+ __all__ = ["Config", "load_config", "get_config_path"]
@@ -0,0 +1,95 @@
1
+ """Configuration loading utilities."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ragnarbot.config.schema import Config
8
+
9
+
10
+ def get_config_path() -> Path:
11
+ """Get the default configuration file path."""
12
+ return Path.home() / ".ragnarbot" / "config.json"
13
+
14
+
15
+ def get_data_dir() -> Path:
16
+ """Get the ragnarbot data directory."""
17
+ from ragnarbot.utils.helpers import get_data_path
18
+ return get_data_path()
19
+
20
+
21
+ def load_config(config_path: Path | None = None) -> Config:
22
+ """
23
+ Load configuration from file or create default.
24
+
25
+ Args:
26
+ config_path: Optional path to config file. Uses default if not provided.
27
+
28
+ Returns:
29
+ Loaded configuration object.
30
+ """
31
+ path = config_path or get_config_path()
32
+
33
+ if path.exists():
34
+ try:
35
+ with open(path) as f:
36
+ data = json.load(f)
37
+ return Config.model_validate(convert_keys(data))
38
+ except (json.JSONDecodeError, ValueError) as e:
39
+ print(f"Warning: Failed to load config from {path}: {e}")
40
+ print("Using default configuration.")
41
+
42
+ return Config()
43
+
44
+
45
+ def save_config(config: Config, config_path: Path | None = None) -> None:
46
+ """
47
+ Save configuration to file.
48
+
49
+ Args:
50
+ config: Configuration to save.
51
+ config_path: Optional path to save to. Uses default if not provided.
52
+ """
53
+ path = config_path or get_config_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ # Convert to camelCase format
57
+ data = config.model_dump()
58
+ data = convert_to_camel(data)
59
+
60
+ with open(path, "w") as f:
61
+ json.dump(data, f, indent=2)
62
+
63
+
64
+ def convert_keys(data: Any) -> Any:
65
+ """Convert camelCase keys to snake_case for Pydantic."""
66
+ if isinstance(data, dict):
67
+ return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
68
+ if isinstance(data, list):
69
+ return [convert_keys(item) for item in data]
70
+ return data
71
+
72
+
73
+ def convert_to_camel(data: Any) -> Any:
74
+ """Convert snake_case keys to camelCase."""
75
+ if isinstance(data, dict):
76
+ return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
77
+ if isinstance(data, list):
78
+ return [convert_to_camel(item) for item in data]
79
+ return data
80
+
81
+
82
+ def camel_to_snake(name: str) -> str:
83
+ """Convert camelCase to snake_case."""
84
+ result = []
85
+ for i, char in enumerate(name):
86
+ if char.isupper() and i > 0:
87
+ result.append("_")
88
+ result.append(char.lower())
89
+ return "".join(result)
90
+
91
+
92
+ def snake_to_camel(name: str) -> str:
93
+ """Convert snake_case to camelCase."""
94
+ components = name.split("_")
95
+ return components[0] + "".join(x.title() for x in components[1:])