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.
- ragnarbot/__init__.py +6 -0
- ragnarbot/__main__.py +8 -0
- ragnarbot/agent/__init__.py +8 -0
- ragnarbot/agent/context.py +223 -0
- ragnarbot/agent/loop.py +365 -0
- ragnarbot/agent/memory.py +109 -0
- ragnarbot/agent/skills.py +228 -0
- ragnarbot/agent/subagent.py +241 -0
- ragnarbot/agent/tools/__init__.py +6 -0
- ragnarbot/agent/tools/base.py +102 -0
- ragnarbot/agent/tools/cron.py +114 -0
- ragnarbot/agent/tools/filesystem.py +191 -0
- ragnarbot/agent/tools/message.py +86 -0
- ragnarbot/agent/tools/registry.py +73 -0
- ragnarbot/agent/tools/shell.py +141 -0
- ragnarbot/agent/tools/spawn.py +65 -0
- ragnarbot/agent/tools/web.py +163 -0
- ragnarbot/bus/__init__.py +6 -0
- ragnarbot/bus/events.py +37 -0
- ragnarbot/bus/queue.py +81 -0
- ragnarbot/channels/__init__.py +6 -0
- ragnarbot/channels/base.py +121 -0
- ragnarbot/channels/manager.py +129 -0
- ragnarbot/channels/telegram.py +302 -0
- ragnarbot/cli/__init__.py +1 -0
- ragnarbot/cli/commands.py +568 -0
- ragnarbot/config/__init__.py +6 -0
- ragnarbot/config/loader.py +95 -0
- ragnarbot/config/schema.py +114 -0
- ragnarbot/cron/__init__.py +6 -0
- ragnarbot/cron/service.py +346 -0
- ragnarbot/cron/types.py +59 -0
- ragnarbot/heartbeat/__init__.py +5 -0
- ragnarbot/heartbeat/service.py +130 -0
- ragnarbot/providers/__init__.py +6 -0
- ragnarbot/providers/base.py +69 -0
- ragnarbot/providers/litellm_provider.py +135 -0
- ragnarbot/providers/transcription.py +67 -0
- ragnarbot/session/__init__.py +5 -0
- ragnarbot/session/manager.py +202 -0
- ragnarbot/skills/README.md +24 -0
- ragnarbot/skills/cron/SKILL.md +40 -0
- ragnarbot/skills/github/SKILL.md +48 -0
- ragnarbot/skills/skill-creator/SKILL.md +371 -0
- ragnarbot/skills/summarize/SKILL.md +67 -0
- ragnarbot/skills/tmux/SKILL.md +121 -0
- ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
- ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
- ragnarbot/skills/weather/SKILL.md +49 -0
- ragnarbot/utils/__init__.py +5 -0
- ragnarbot/utils/helpers.py +91 -0
- ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
- ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
- ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
- ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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:])
|