operator-agent 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.
- operator_agent/__init__.py +8 -0
- operator_agent/cli.py +594 -0
- operator_agent/config.py +64 -0
- operator_agent/core.py +376 -0
- operator_agent/providers/__init__.py +86 -0
- operator_agent/providers/claude.py +108 -0
- operator_agent/providers/codex.py +115 -0
- operator_agent/providers/gemini.py +95 -0
- operator_agent/transports/__init__.py +25 -0
- operator_agent/transports/telegram.py +305 -0
- operator_agent-0.1.0.dist-info/METADATA +191 -0
- operator_agent-0.1.0.dist-info/RECORD +16 -0
- operator_agent-0.1.0.dist-info/WHEEL +5 -0
- operator_agent-0.1.0.dist-info/entry_points.txt +2 -0
- operator_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- operator_agent-0.1.0.dist-info/top_level.txt +1 -0
operator_agent/cli.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""CLI entry point for Operator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.prompt import Confirm, Prompt
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import CONFIG_FILE, detect_providers, load_config, save_config
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(help="Operator - Personal AI Agent", no_args_is_help=True)
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
|
|
30
|
+
|
|
31
|
+
_NOISY_LOGGERS = (
|
|
32
|
+
"httpx",
|
|
33
|
+
"httpcore",
|
|
34
|
+
"telegram",
|
|
35
|
+
"telegram.ext",
|
|
36
|
+
"telegram.ext._application",
|
|
37
|
+
"telegram.ext._updater",
|
|
38
|
+
"telegram.ext._base_update_handler",
|
|
39
|
+
"telegram._bot",
|
|
40
|
+
"hpack",
|
|
41
|
+
"urllib3",
|
|
42
|
+
"h11",
|
|
43
|
+
"h2",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Telegram API helpers (stdlib only, no extra deps) ---
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tg_call(token: str, method: str, **params) -> dict:
|
|
51
|
+
"""Call a Telegram Bot API method. Returns the parsed JSON response."""
|
|
52
|
+
url = TELEGRAM_API.format(token=token, method=method)
|
|
53
|
+
if params:
|
|
54
|
+
data = json.dumps(params).encode()
|
|
55
|
+
req = urllib.request.Request(
|
|
56
|
+
url, data=data, headers={"Content-Type": "application/json"}
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
req = urllib.request.Request(url)
|
|
60
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
61
|
+
return json.loads(resp.read())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tg_validate_token(token: str) -> dict | None:
|
|
65
|
+
"""Validate a bot token via getMe. Returns bot info or None."""
|
|
66
|
+
try:
|
|
67
|
+
result = _tg_call(token, "getMe")
|
|
68
|
+
if result.get("ok"):
|
|
69
|
+
return result["result"]
|
|
70
|
+
except Exception:
|
|
71
|
+
log.debug("Token validation failed", exc_info=True)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tg_clear_updates(token: str) -> None:
|
|
76
|
+
"""Clear any pending updates so we only capture fresh messages."""
|
|
77
|
+
try:
|
|
78
|
+
result = _tg_call(token, "getUpdates", offset=-1, timeout=0)
|
|
79
|
+
if result.get("ok") and result.get("result"):
|
|
80
|
+
last_id = result["result"][-1]["update_id"]
|
|
81
|
+
_tg_call(token, "getUpdates", offset=last_id + 1, timeout=0)
|
|
82
|
+
except Exception:
|
|
83
|
+
log.debug("Failed to clear updates", exc_info=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _tg_wait_for_message(token: str, timeout: int = 120) -> dict | None:
|
|
87
|
+
"""Poll for the first message. Returns user/chat info or None on timeout."""
|
|
88
|
+
_tg_clear_updates(token)
|
|
89
|
+
|
|
90
|
+
start = time.time()
|
|
91
|
+
last_update_id = 0
|
|
92
|
+
while time.time() - start < timeout:
|
|
93
|
+
try:
|
|
94
|
+
params = {"timeout": 5}
|
|
95
|
+
if last_update_id:
|
|
96
|
+
params["offset"] = last_update_id + 1
|
|
97
|
+
result = _tg_call(token, "getUpdates", **params)
|
|
98
|
+
if result.get("ok"):
|
|
99
|
+
for update in result.get("result", []):
|
|
100
|
+
last_update_id = update["update_id"]
|
|
101
|
+
msg = update.get("message")
|
|
102
|
+
if msg and msg.get("from"):
|
|
103
|
+
_tg_call(
|
|
104
|
+
token,
|
|
105
|
+
"getUpdates",
|
|
106
|
+
offset=last_update_id + 1,
|
|
107
|
+
timeout=0,
|
|
108
|
+
)
|
|
109
|
+
user = msg["from"]
|
|
110
|
+
return {
|
|
111
|
+
"user_id": user.get("id"),
|
|
112
|
+
"username": user.get("username"),
|
|
113
|
+
"first_name": user.get("first_name"),
|
|
114
|
+
"chat_id": msg["chat"]["id"],
|
|
115
|
+
}
|
|
116
|
+
except Exception:
|
|
117
|
+
log.debug("Polling error", exc_info=True)
|
|
118
|
+
time.sleep(1)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _tg_send_message(token: str, chat_id: int, text: str) -> bool:
|
|
123
|
+
"""Send a message. Returns True on success."""
|
|
124
|
+
try:
|
|
125
|
+
result = _tg_call(token, "sendMessage", chat_id=chat_id, text=text)
|
|
126
|
+
return result.get("ok", False)
|
|
127
|
+
except Exception:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Service installation ---
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _find_operator_bin() -> str | None:
|
|
135
|
+
"""Locate the operator binary."""
|
|
136
|
+
operator_bin = shutil.which("operator")
|
|
137
|
+
if not operator_bin:
|
|
138
|
+
venv_bin = os.path.join(sys.prefix, "bin", "operator")
|
|
139
|
+
if os.path.exists(venv_bin):
|
|
140
|
+
operator_bin = venv_bin
|
|
141
|
+
return operator_bin
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_path_str(operator_bin: str) -> str:
|
|
145
|
+
"""Build a PATH string from detected CLI locations."""
|
|
146
|
+
path_dirs: set[str] = set()
|
|
147
|
+
path_dirs.add(os.path.dirname(operator_bin))
|
|
148
|
+
for cmd in ("claude", "codex", "gemini", "node", "npx"):
|
|
149
|
+
p = shutil.which(cmd)
|
|
150
|
+
if p:
|
|
151
|
+
path_dirs.add(os.path.dirname(p))
|
|
152
|
+
path_dirs.update(["/usr/local/bin", "/usr/bin", "/bin"])
|
|
153
|
+
return ":".join(sorted(path_dirs))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _setup_service(config: dict) -> str | None:
|
|
157
|
+
"""Dispatch to platform-specific service installer. Returns platform name if installed."""
|
|
158
|
+
if sys.platform == "linux":
|
|
159
|
+
return "systemd" if _setup_systemd(config) else None
|
|
160
|
+
elif sys.platform == "darwin":
|
|
161
|
+
return "launchd" if _setup_launchd(config) else None
|
|
162
|
+
else:
|
|
163
|
+
console.print(
|
|
164
|
+
f"[yellow]Automatic service install not supported on {sys.platform}[/]"
|
|
165
|
+
)
|
|
166
|
+
console.print("Run [bold]operator serve[/] manually to start.")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _setup_systemd(config: dict) -> bool:
|
|
171
|
+
"""Offer to install a systemd user service. Returns True if installed."""
|
|
172
|
+
service_dir = os.path.expanduser("~/.config/systemd/user")
|
|
173
|
+
service_path = os.path.join(service_dir, "operator.service")
|
|
174
|
+
|
|
175
|
+
if os.path.exists(service_path):
|
|
176
|
+
console.print(" systemd service already exists.")
|
|
177
|
+
if not Confirm.ask(" Update it?", default=False):
|
|
178
|
+
return False
|
|
179
|
+
else:
|
|
180
|
+
if not Confirm.ask(" Install systemd service to run on boot?", default=True):
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
operator_bin = _find_operator_bin()
|
|
184
|
+
if not operator_bin:
|
|
185
|
+
console.print("[yellow] Could not find 'operator' binary. Skipping service install.[/]")
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
path_str = _build_path_str(operator_bin)
|
|
189
|
+
working_dir = config.get("working_dir", os.getcwd())
|
|
190
|
+
|
|
191
|
+
unit = f"""\
|
|
192
|
+
[Unit]
|
|
193
|
+
Description=Operator - Personal AI Agent
|
|
194
|
+
After=network.target
|
|
195
|
+
|
|
196
|
+
[Service]
|
|
197
|
+
Type=simple
|
|
198
|
+
WorkingDirectory={working_dir}
|
|
199
|
+
ExecStart={operator_bin} serve
|
|
200
|
+
Restart=always
|
|
201
|
+
RestartSec=5
|
|
202
|
+
Environment=PYTHONUNBUFFERED=1
|
|
203
|
+
Environment=PATH={path_str}
|
|
204
|
+
|
|
205
|
+
[Install]
|
|
206
|
+
WantedBy=default.target
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
os.makedirs(service_dir, exist_ok=True)
|
|
210
|
+
with open(service_path, "w") as f:
|
|
211
|
+
f.write(unit)
|
|
212
|
+
|
|
213
|
+
console.print(f" [green]\u2713[/] Service written to {service_path}")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
217
|
+
env = {
|
|
218
|
+
**os.environ,
|
|
219
|
+
"XDG_RUNTIME_DIR": xdg,
|
|
220
|
+
"DBUS_SESSION_BUS_ADDRESS": f"unix:path={xdg}/bus",
|
|
221
|
+
}
|
|
222
|
+
subprocess.run(
|
|
223
|
+
["systemctl", "--user", "daemon-reload"],
|
|
224
|
+
env=env,
|
|
225
|
+
capture_output=True,
|
|
226
|
+
)
|
|
227
|
+
subprocess.run(
|
|
228
|
+
["systemctl", "--user", "enable", "--now", "operator.service"],
|
|
229
|
+
env=env,
|
|
230
|
+
capture_output=True,
|
|
231
|
+
)
|
|
232
|
+
console.print(" [green]\u2713[/] Service enabled and started.")
|
|
233
|
+
except Exception:
|
|
234
|
+
console.print(" Service file written. Start it with:")
|
|
235
|
+
console.print(" systemctl --user daemon-reload")
|
|
236
|
+
console.print(" systemctl --user enable --now operator.service")
|
|
237
|
+
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _setup_launchd(config: dict) -> bool:
|
|
242
|
+
"""Offer to install a macOS launchd user agent. Returns True if installed."""
|
|
243
|
+
plist_dir = os.path.expanduser("~/Library/LaunchAgents")
|
|
244
|
+
plist_path = os.path.join(plist_dir, "com.operator.agent.plist")
|
|
245
|
+
|
|
246
|
+
if os.path.exists(plist_path):
|
|
247
|
+
console.print(" launchd agent already exists.")
|
|
248
|
+
if not Confirm.ask(" Update it?", default=False):
|
|
249
|
+
return False
|
|
250
|
+
else:
|
|
251
|
+
if not Confirm.ask(" Install launchd agent to run on login?", default=True):
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
operator_bin = _find_operator_bin()
|
|
255
|
+
if not operator_bin:
|
|
256
|
+
console.print("[yellow] Could not find 'operator' binary. Skipping service install.[/]")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
path_str = _build_path_str(operator_bin)
|
|
260
|
+
working_dir = config.get("working_dir", os.getcwd())
|
|
261
|
+
log_path = os.path.expanduser("~/.operator/operator.log")
|
|
262
|
+
|
|
263
|
+
plist = f"""\
|
|
264
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
265
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" \
|
|
266
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
267
|
+
<plist version="1.0">
|
|
268
|
+
<dict>
|
|
269
|
+
<key>Label</key>
|
|
270
|
+
<string>com.operator.agent</string>
|
|
271
|
+
<key>ProgramArguments</key>
|
|
272
|
+
<array>
|
|
273
|
+
<string>{operator_bin}</string>
|
|
274
|
+
<string>serve</string>
|
|
275
|
+
</array>
|
|
276
|
+
<key>WorkingDirectory</key>
|
|
277
|
+
<string>{working_dir}</string>
|
|
278
|
+
<key>EnvironmentVariables</key>
|
|
279
|
+
<dict>
|
|
280
|
+
<key>PATH</key>
|
|
281
|
+
<string>{path_str}</string>
|
|
282
|
+
<key>PYTHONUNBUFFERED</key>
|
|
283
|
+
<string>1</string>
|
|
284
|
+
</dict>
|
|
285
|
+
<key>RunAtLoad</key>
|
|
286
|
+
<true/>
|
|
287
|
+
<key>KeepAlive</key>
|
|
288
|
+
<true/>
|
|
289
|
+
<key>StandardOutPath</key>
|
|
290
|
+
<string>{log_path}</string>
|
|
291
|
+
<key>StandardErrorPath</key>
|
|
292
|
+
<string>{log_path}</string>
|
|
293
|
+
</dict>
|
|
294
|
+
</plist>
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
os.makedirs(plist_dir, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
if os.path.exists(plist_path):
|
|
300
|
+
subprocess.run(["launchctl", "unload", plist_path], capture_output=True)
|
|
301
|
+
|
|
302
|
+
with open(plist_path, "w") as f:
|
|
303
|
+
f.write(plist)
|
|
304
|
+
|
|
305
|
+
console.print(f" [green]\u2713[/] Plist written to {plist_path}")
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
subprocess.run(["launchctl", "load", plist_path], capture_output=True, check=True)
|
|
309
|
+
console.print(" [green]\u2713[/] Agent loaded and started.")
|
|
310
|
+
except Exception:
|
|
311
|
+
console.print(" Plist written. Load it with:")
|
|
312
|
+
console.print(f" launchctl load {plist_path}")
|
|
313
|
+
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_service_running(service: str) -> None:
|
|
318
|
+
"""Check if the installed service is running and report status."""
|
|
319
|
+
try:
|
|
320
|
+
if service == "launchd":
|
|
321
|
+
result = subprocess.run(
|
|
322
|
+
["launchctl", "list", "com.operator.agent"],
|
|
323
|
+
capture_output=True,
|
|
324
|
+
)
|
|
325
|
+
running = result.returncode == 0
|
|
326
|
+
elif service == "systemd":
|
|
327
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
328
|
+
env = {
|
|
329
|
+
**os.environ,
|
|
330
|
+
"XDG_RUNTIME_DIR": xdg,
|
|
331
|
+
"DBUS_SESSION_BUS_ADDRESS": f"unix:path={xdg}/bus",
|
|
332
|
+
}
|
|
333
|
+
result = subprocess.run(
|
|
334
|
+
["systemctl", "--user", "is-active", "--quiet", "operator.service"],
|
|
335
|
+
env=env,
|
|
336
|
+
capture_output=True,
|
|
337
|
+
)
|
|
338
|
+
running = result.returncode == 0
|
|
339
|
+
else:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if running:
|
|
343
|
+
console.print("[green]\u2713[/] Service is running.")
|
|
344
|
+
else:
|
|
345
|
+
console.print("[yellow]! Service is not running.[/]")
|
|
346
|
+
except Exception:
|
|
347
|
+
log.debug("Service status check failed", exc_info=True)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --- Setup wizard steps ---
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _setup_providers():
|
|
354
|
+
"""Step 1: detect and display available provider CLIs."""
|
|
355
|
+
console.rule("[bold]Step 1 \u00b7 Provider Detection")
|
|
356
|
+
|
|
357
|
+
available = detect_providers()
|
|
358
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
359
|
+
table.add_column("status", width=3)
|
|
360
|
+
table.add_column("name")
|
|
361
|
+
for name, found in available.items():
|
|
362
|
+
if found:
|
|
363
|
+
table.add_row("[green]\u2713[/]", name)
|
|
364
|
+
else:
|
|
365
|
+
table.add_row("[red]\u2717[/]", f"[dim]{name}[/]")
|
|
366
|
+
console.print(table)
|
|
367
|
+
|
|
368
|
+
if not any(available.values()):
|
|
369
|
+
console.print()
|
|
370
|
+
console.print("[yellow]No provider CLIs found on PATH.[/]")
|
|
371
|
+
console.print("Install at least one of: claude, codex, gemini")
|
|
372
|
+
console.print("[dim]You can continue setup and install providers later.[/]")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _setup_telegram(config: dict) -> int | None:
|
|
376
|
+
"""Step 2: configure Telegram bot and capture user. Returns chat_id or None."""
|
|
377
|
+
console.rule("[bold]Step 2 \u00b7 Telegram Bot Setup")
|
|
378
|
+
|
|
379
|
+
current_token = config.get("telegram", {}).get("bot_token", "")
|
|
380
|
+
current_users = config.get("telegram", {}).get("allowed_user_ids", [])
|
|
381
|
+
bot_info = None
|
|
382
|
+
|
|
383
|
+
# Check existing config
|
|
384
|
+
if current_token:
|
|
385
|
+
bot_info = _tg_validate_token(current_token)
|
|
386
|
+
if bot_info:
|
|
387
|
+
bot_name = bot_info.get("username", "unknown")
|
|
388
|
+
console.print(f" Current bot: [bold]@{bot_name}[/]")
|
|
389
|
+
if current_users:
|
|
390
|
+
console.print(f" Allowed users: {current_users}")
|
|
391
|
+
if not Confirm.ask("\n Reconfigure Telegram?", default=False):
|
|
392
|
+
console.print(" Keeping current Telegram config.")
|
|
393
|
+
return None
|
|
394
|
+
current_users = []
|
|
395
|
+
else:
|
|
396
|
+
console.print("[yellow] Existing token is invalid, let's set up a new one.[/]")
|
|
397
|
+
current_token = ""
|
|
398
|
+
|
|
399
|
+
# Prompt for new token
|
|
400
|
+
console.print(" To connect Operator to Telegram, you need a bot token.\n")
|
|
401
|
+
console.print(" If you don't have one yet:")
|
|
402
|
+
console.print(" 1. Open Telegram and message @BotFather")
|
|
403
|
+
console.print(" 2. Send /newbot")
|
|
404
|
+
console.print(" 3. Choose a name and username for your bot")
|
|
405
|
+
console.print(" 4. BotFather will send you a token\n")
|
|
406
|
+
|
|
407
|
+
while True:
|
|
408
|
+
token = Prompt.ask(" Bot token")
|
|
409
|
+
if not token:
|
|
410
|
+
console.print("[red] Token is required.[/]")
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
with console.status("Validating token..."):
|
|
414
|
+
bot_info = _tg_validate_token(token)
|
|
415
|
+
|
|
416
|
+
if bot_info:
|
|
417
|
+
bot_name = bot_info.get("username", "unknown")
|
|
418
|
+
console.print(f" [green]\u2713[/] Connected! Bot: [bold]@{bot_name}[/]")
|
|
419
|
+
config.setdefault("telegram", {})["bot_token"] = token
|
|
420
|
+
current_token = token
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
console.print("[red] \u2717 Could not connect. Check the token and try again.[/]")
|
|
424
|
+
|
|
425
|
+
# Auto-capture user ID
|
|
426
|
+
if current_users:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
bot_name = bot_info.get("username", "unknown")
|
|
430
|
+
console.print("\n Now let's link your Telegram account.")
|
|
431
|
+
console.print(f" Send any message to [bold]@{bot_name}[/] in Telegram.\n")
|
|
432
|
+
|
|
433
|
+
with console.status("Waiting for message..."):
|
|
434
|
+
user_info = _tg_wait_for_message(current_token, timeout=120)
|
|
435
|
+
|
|
436
|
+
if not (user_info and user_info.get("user_id")):
|
|
437
|
+
console.print("[yellow] Timed out. No message received.[/]")
|
|
438
|
+
console.print(" You can add your user ID manually.")
|
|
439
|
+
console.print(" Edit ~/.operator/config.json \u2192 telegram.allowed_user_ids")
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
user_id = user_info["user_id"]
|
|
443
|
+
name = user_info.get("first_name") or user_info.get("username") or "?"
|
|
444
|
+
console.print(f" [green]\u2713[/] Got it! {name} (ID: {user_id})")
|
|
445
|
+
config.setdefault("telegram", {})["allowed_user_ids"] = [user_id]
|
|
446
|
+
return user_info.get("chat_id")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# --- Commands ---
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@app.command()
|
|
453
|
+
def setup():
|
|
454
|
+
"""Interactive setup wizard."""
|
|
455
|
+
console.print(Panel("Operator Setup", subtitle=f"v{__version__}", expand=False))
|
|
456
|
+
config = load_config()
|
|
457
|
+
|
|
458
|
+
_setup_providers()
|
|
459
|
+
captured_chat_id = _setup_telegram(config)
|
|
460
|
+
|
|
461
|
+
# Step 3: Working directory
|
|
462
|
+
console.rule("[bold]Step 3 \u00b7 Working Directory")
|
|
463
|
+
console.print(" This is where Operator runs agent commands from.")
|
|
464
|
+
console.print(" Agents read and create files here to solve tasks.\n")
|
|
465
|
+
current_dir = config.get("working_dir", os.getcwd())
|
|
466
|
+
new_dir = Prompt.ask(" Working directory", default=current_dir)
|
|
467
|
+
if new_dir != current_dir:
|
|
468
|
+
config["working_dir"] = os.path.abspath(new_dir)
|
|
469
|
+
|
|
470
|
+
# Save config
|
|
471
|
+
with console.status("Saving config..."):
|
|
472
|
+
save_config(config)
|
|
473
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
474
|
+
console.print(f"[green]\u2713[/] Config saved to {CONFIG_FILE}")
|
|
475
|
+
|
|
476
|
+
# Send test message
|
|
477
|
+
token = config.get("telegram", {}).get("bot_token", "")
|
|
478
|
+
users = config.get("telegram", {}).get("allowed_user_ids", [])
|
|
479
|
+
chat_id = captured_chat_id or (users[0] if users else None)
|
|
480
|
+
if token and chat_id:
|
|
481
|
+
with console.status("Sending test message..."):
|
|
482
|
+
sent = _tg_send_message(
|
|
483
|
+
token, chat_id,
|
|
484
|
+
f"Operator v{__version__} setup complete. Ready to go.",
|
|
485
|
+
)
|
|
486
|
+
if sent:
|
|
487
|
+
console.print("[green]\u2713[/] Test message sent! Check Telegram.")
|
|
488
|
+
else:
|
|
489
|
+
console.print(
|
|
490
|
+
"[yellow]Failed to send test message. You can test later with 'operator serve'.[/]"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Step 4: Background service
|
|
494
|
+
console.rule("[bold]Step 4 \u00b7 Background Service (optional)")
|
|
495
|
+
service = _setup_service(config)
|
|
496
|
+
|
|
497
|
+
# Summary
|
|
498
|
+
summary = Table(show_header=False, box=None, padding=(0, 2))
|
|
499
|
+
summary.add_column("key", style="bold")
|
|
500
|
+
summary.add_column("value")
|
|
501
|
+
summary.add_row("Config", str(CONFIG_FILE))
|
|
502
|
+
|
|
503
|
+
if service == "launchd":
|
|
504
|
+
_check_service_running(service)
|
|
505
|
+
log_path = "~/.operator/operator.log"
|
|
506
|
+
summary.add_row("Logs", f"tail -f {log_path}")
|
|
507
|
+
summary.add_row("Stop", "launchctl unload ~/Library/LaunchAgents/com.operator.agent.plist")
|
|
508
|
+
summary.add_row("Start", "launchctl load ~/Library/LaunchAgents/com.operator.agent.plist")
|
|
509
|
+
elif service == "systemd":
|
|
510
|
+
_check_service_running(service)
|
|
511
|
+
summary.add_row("Logs", "journalctl --user -u operator -f")
|
|
512
|
+
summary.add_row("Stop", "systemctl --user stop operator")
|
|
513
|
+
summary.add_row("Start", "systemctl --user start operator")
|
|
514
|
+
summary.add_row("Restart", "systemctl --user restart operator")
|
|
515
|
+
else:
|
|
516
|
+
summary.add_row("Run", "operator serve")
|
|
517
|
+
|
|
518
|
+
console.print(Panel(summary, title="Setup Complete", border_style="green", expand=False))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@app.command()
|
|
522
|
+
def serve(
|
|
523
|
+
transport: Annotated[
|
|
524
|
+
str, typer.Option(help="Transport to use")
|
|
525
|
+
] = "telegram",
|
|
526
|
+
working_dir: Annotated[
|
|
527
|
+
str | None, typer.Option("--working-dir", help="Override working directory")
|
|
528
|
+
] = None,
|
|
529
|
+
):
|
|
530
|
+
"""Start the bot."""
|
|
531
|
+
from .core import Runtime
|
|
532
|
+
|
|
533
|
+
config = load_config()
|
|
534
|
+
|
|
535
|
+
if working_dir:
|
|
536
|
+
config["working_dir"] = working_dir
|
|
537
|
+
|
|
538
|
+
wd = config.get("working_dir", os.getcwd())
|
|
539
|
+
os.chdir(wd)
|
|
540
|
+
|
|
541
|
+
runtime = Runtime(config)
|
|
542
|
+
runtime.init_config_dir()
|
|
543
|
+
runtime.load_state()
|
|
544
|
+
|
|
545
|
+
log = logging.getLogger("operator_agent")
|
|
546
|
+
log.info("Starting Operator v%s", __version__)
|
|
547
|
+
log.info(" working_dir=%s", wd)
|
|
548
|
+
|
|
549
|
+
if transport == "telegram":
|
|
550
|
+
from .transports.telegram import TelegramTransport
|
|
551
|
+
|
|
552
|
+
bot_token = config.get("telegram", {}).get("bot_token", "")
|
|
553
|
+
if not bot_token:
|
|
554
|
+
console.print("[red]Error: No Telegram bot token configured.[/]")
|
|
555
|
+
console.print("Run [bold]operator setup[/] to configure.")
|
|
556
|
+
raise typer.Exit(1)
|
|
557
|
+
|
|
558
|
+
t = TelegramTransport(runtime)
|
|
559
|
+
t.start()
|
|
560
|
+
else:
|
|
561
|
+
console.print(f"[red]Unknown transport: {transport}[/]")
|
|
562
|
+
raise typer.Exit(1)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# --- App setup ---
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _version_callback(value: bool):
|
|
569
|
+
if value:
|
|
570
|
+
console.print(f"operator {__version__}")
|
|
571
|
+
raise typer.Exit()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@app.callback()
|
|
575
|
+
def _main(
|
|
576
|
+
version: Annotated[
|
|
577
|
+
bool, typer.Option("--version", callback=_version_callback, is_eager=True)
|
|
578
|
+
] = False,
|
|
579
|
+
):
|
|
580
|
+
"""Operator - Personal AI Agent."""
|
|
581
|
+
logging.basicConfig(
|
|
582
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
583
|
+
level=logging.INFO,
|
|
584
|
+
)
|
|
585
|
+
for name in _NOISY_LOGGERS:
|
|
586
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def main():
|
|
590
|
+
app()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
main()
|
operator_agent/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Configuration management for Operator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = os.path.expanduser("~/.operator")
|
|
13
|
+
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
14
|
+
STATE_FILE = os.path.join(CONFIG_DIR, "state.json")
|
|
15
|
+
|
|
16
|
+
DEFAULT_PROVIDERS = {
|
|
17
|
+
"claude": {"path": "claude", "models": ["opus", "sonnet", "haiku"]},
|
|
18
|
+
"codex": {"path": "codex", "models": ["gpt-5.3-codex"]},
|
|
19
|
+
"gemini": {"path": "gemini", "models": ["gemini-2.5-pro", "gemini-2.5-flash"]},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> dict:
|
|
24
|
+
"""Load config from ~/.operator/config.json, creating defaults if needed."""
|
|
25
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
if os.path.exists(CONFIG_FILE):
|
|
28
|
+
try:
|
|
29
|
+
with open(CONFIG_FILE) as f:
|
|
30
|
+
config = json.load(f)
|
|
31
|
+
log.debug("Loaded config from %s", CONFIG_FILE)
|
|
32
|
+
return config
|
|
33
|
+
except Exception:
|
|
34
|
+
log.exception("Failed to load config.json, using defaults")
|
|
35
|
+
|
|
36
|
+
config = _build_default_config()
|
|
37
|
+
save_config(config)
|
|
38
|
+
return config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_config(config: dict) -> None:
|
|
42
|
+
"""Save config to ~/.operator/config.json."""
|
|
43
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
44
|
+
with open(CONFIG_FILE, "w") as f:
|
|
45
|
+
json.dump(config, f, indent=2)
|
|
46
|
+
f.write("\n")
|
|
47
|
+
log.debug("Saved config to %s", CONFIG_FILE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_default_config() -> dict:
|
|
51
|
+
"""Build default config with all providers and their models."""
|
|
52
|
+
return {
|
|
53
|
+
"working_dir": os.getcwd(),
|
|
54
|
+
"telegram": {
|
|
55
|
+
"bot_token": "",
|
|
56
|
+
"allowed_user_ids": [],
|
|
57
|
+
},
|
|
58
|
+
"providers": dict(DEFAULT_PROVIDERS),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def detect_providers() -> dict[str, bool]:
|
|
63
|
+
"""Check which provider CLIs are available on PATH."""
|
|
64
|
+
return {name: shutil.which(name) is not None for name in DEFAULT_PROVIDERS}
|