agentwatch-cli 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.
@@ -0,0 +1,19 @@
1
+ """
2
+ agentwatch-cli: Connect your local Moltbot gateway to AgentWatch cloud.
3
+
4
+ This package provides a connector that allows AgentWatch to communicate with
5
+ your local Moltbot (OpenClaw) gateway without exposing your local network.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "AgentWatch"
10
+
11
+ from .connector import MoltbotConnector
12
+ from .config import ConnectorConfig, load_config, save_config
13
+
14
+ __all__ = [
15
+ "MoltbotConnector",
16
+ "ConnectorConfig",
17
+ "load_config",
18
+ "save_config",
19
+ ]
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m agentwatch_cli."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
agentwatch_cli/cli.py ADDED
@@ -0,0 +1,522 @@
1
+ """
2
+ CLI entry point for agentwatch-cli.
3
+ """
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import shutil
10
+ import stat
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import httpx
16
+
17
+
18
+ def fix_script_permissions() -> bool:
19
+ """
20
+ Find and fix permissions on the agentwatch-cli script.
21
+
22
+ Returns:
23
+ True if permissions were fixed, False otherwise.
24
+ """
25
+ # Find the script location
26
+ script_path = shutil.which("agentwatch-cli")
27
+
28
+ if not script_path:
29
+ # Try common locations
30
+ possible_paths = [
31
+ Path.home() / ".local" / "bin" / "agentwatch-cli",
32
+ Path.home() / "Library" / "Python" / "3.9" / "bin" / "agentwatch-cli",
33
+ Path.home() / "Library" / "Python" / "3.10" / "bin" / "agentwatch-cli",
34
+ Path.home() / "Library" / "Python" / "3.11" / "bin" / "agentwatch-cli",
35
+ Path.home() / "Library" / "Python" / "3.12" / "bin" / "agentwatch-cli",
36
+ ]
37
+ for path in possible_paths:
38
+ if path.exists():
39
+ script_path = str(path)
40
+ break
41
+
42
+ if not script_path:
43
+ return False
44
+
45
+ try:
46
+ path = Path(script_path)
47
+ # Add execute permission for owner, group, and others
48
+ current_mode = path.stat().st_mode
49
+ new_mode = current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
50
+ path.chmod(new_mode)
51
+ return True
52
+ except Exception as e:
53
+ print(f"Warning: Could not fix script permissions: {e}")
54
+ return False
55
+
56
+ from .config import (
57
+ ConnectorConfig,
58
+ load_config,
59
+ save_config,
60
+ discover_gateway_token,
61
+ get_effective_gateway_token,
62
+ DEFAULT_CONFIG_FILE,
63
+ )
64
+ from .connector import MoltbotConnector, test_gateway_connection
65
+ from .service import install_service, uninstall_service, get_service_status
66
+
67
+ def find_openclaw_config() -> Optional[Path]:
68
+ """Find the OpenClaw config file."""
69
+ # Check home directory first, then current directory
70
+ search_paths = [
71
+ Path.home() / ".openclaw" / "openclaw.json",
72
+ Path.cwd() / "openclaw.json",
73
+ ]
74
+ for path in search_paths:
75
+ if path.exists():
76
+ return path
77
+ return None
78
+
79
+
80
+ def ensure_openclaw_http_enabled() -> bool:
81
+ """
82
+ Ensure OpenClaw's HTTP chat completions endpoint is enabled.
83
+
84
+ Returns:
85
+ True if config was updated, False if already enabled or file not found
86
+ """
87
+ config_path = find_openclaw_config()
88
+ if not config_path:
89
+ return False
90
+
91
+ try:
92
+ with open(config_path, "r") as f:
93
+ config = json.load(f)
94
+
95
+ # Navigate to gateway.http.endpoints.chatCompletions.enabled
96
+ # Create nested dicts if they don't exist
97
+ if "gateway" not in config:
98
+ config["gateway"] = {}
99
+ if "http" not in config["gateway"]:
100
+ config["gateway"]["http"] = {}
101
+ if "endpoints" not in config["gateway"]["http"]:
102
+ config["gateway"]["http"]["endpoints"] = {}
103
+ if "chatCompletions" not in config["gateway"]["http"]["endpoints"]:
104
+ config["gateway"]["http"]["endpoints"]["chatCompletions"] = {}
105
+
106
+ # Check if already enabled
107
+ if config["gateway"]["http"]["endpoints"]["chatCompletions"].get("enabled") == True:
108
+ return False
109
+
110
+ # Enable it
111
+ config["gateway"]["http"]["endpoints"]["chatCompletions"]["enabled"] = True
112
+
113
+ with open(config_path, "w") as f:
114
+ json.dump(config, f, indent=2)
115
+
116
+ return True
117
+ except (json.JSONDecodeError, IOError) as e:
118
+ print(f"Warning: Could not update OpenClaw config: {e}")
119
+ return False
120
+
121
+
122
+ def normalize_enrollment_code(code: str) -> str:
123
+ """Normalize enrollment code to XXXX-XXXX format."""
124
+ # Remove any dashes and whitespace, uppercase
125
+ clean = code.replace("-", "").replace(" ", "").upper()
126
+ if len(clean) == 8:
127
+ return f"{clean[:4]}-{clean[4:]}"
128
+ return code.upper()
129
+
130
+
131
+ def enroll_command(args: argparse.Namespace) -> int:
132
+ """Handle the enroll command."""
133
+ enrollment_code = normalize_enrollment_code(args.code)
134
+
135
+ print(f"Enrolling with code: {enrollment_code}")
136
+
137
+ # Load existing config or create new
138
+ config = load_config()
139
+
140
+ # Determine enrollment endpoint
141
+ # First try environment variable, then default
142
+ import os
143
+
144
+ enrollment_url = os.environ.get(
145
+ "AGENTWATCH_ENROLLMENT_URL",
146
+ "https://agentwatch-api-production.up.railway.app/api/connector/enroll",
147
+ )
148
+
149
+ try:
150
+ # Call enrollment API
151
+ with httpx.Client(timeout=30.0) as client:
152
+ response = client.post(
153
+ enrollment_url,
154
+ json={"enrollment_code": enrollment_code},
155
+ )
156
+
157
+ if response.status_code == 429:
158
+ # Rate limited
159
+ try:
160
+ error_data = response.json()
161
+ retry_after = error_data.get('retry_after', 900)
162
+ print(f"Rate limited: Too many enrollment attempts.")
163
+ print(f"Please try again in {retry_after // 60} minutes.")
164
+ except json.JSONDecodeError:
165
+ print("Rate limited: Too many enrollment attempts. Please try again later.")
166
+ return 1
167
+
168
+ if response.status_code != 200:
169
+ try:
170
+ error_data = response.json()
171
+ print(f"Enrollment failed: {error_data.get('error', 'Unknown error')}")
172
+ except json.JSONDecodeError:
173
+ print(f"Enrollment failed: HTTP {response.status_code}")
174
+ return 1
175
+
176
+ data = response.json()
177
+
178
+ if not data.get("success"):
179
+ print(f"Enrollment failed: {data.get('error', 'Unknown error')}")
180
+ return 1
181
+
182
+ # Update config with enrollment data
183
+ config.connector_id = data["connector_id"]
184
+ config.secret = data["secret"]
185
+ config.agent_id = data["agent_id"]
186
+ config.agent_name = data["agent_name"]
187
+ config.agentwatch_url = data.get("agentwatch_url", config.agentwatch_url)
188
+
189
+ # Try to auto-discover gateway token
190
+ discovered_token = discover_gateway_token()
191
+ if discovered_token:
192
+ print("Auto-discovered gateway token from ~/.openclaw/openclaw.json")
193
+ # Don't save the token - let it be discovered each time for security
194
+
195
+ # Save config
196
+ save_config(config)
197
+
198
+ # Fix script permissions (needed for pipx on macOS)
199
+ if fix_script_permissions():
200
+ print("Fixed script permissions")
201
+
202
+ # Enable OpenClaw HTTP endpoint
203
+ if ensure_openclaw_http_enabled():
204
+ print("Enabled OpenClaw HTTP chat completions endpoint")
205
+ print("Note: You may need to restart OpenClaw for changes to take effect")
206
+
207
+ print()
208
+ print("=" * 50)
209
+ print("Enrollment successful!")
210
+ print("=" * 50)
211
+ print(f"Agent: {config.agent_name}")
212
+ print(f"Config saved to: {DEFAULT_CONFIG_FILE}")
213
+ print()
214
+ print("To start the connector, run:")
215
+ print()
216
+ print(" agentwatch-cli start")
217
+ print()
218
+ print("Or install as a background service:")
219
+ print()
220
+ print(" agentwatch-cli install-service")
221
+ print()
222
+
223
+ return 0
224
+
225
+ except httpx.ConnectError:
226
+ print(f"Failed to connect to enrollment server at {enrollment_url}")
227
+ print("Please check your internet connection.")
228
+ return 1
229
+ except Exception as e:
230
+ print(f"Enrollment error: {e}")
231
+ return 1
232
+
233
+
234
+ def start_command(args: argparse.Namespace) -> int:
235
+ """Handle the start command."""
236
+ config = load_config()
237
+
238
+ if not config.is_enrolled():
239
+ print("Connector is not enrolled.")
240
+ print("Please run: agentwatch-cli enroll --code <YOUR_CODE>")
241
+ return 1
242
+
243
+ # Apply command line overrides
244
+ if args.gateway_url:
245
+ config.gateway_url = args.gateway_url
246
+ if args.gateway_token:
247
+ config.gateway_token = args.gateway_token
248
+
249
+ print(f"Starting connector for agent: {config.agent_name}")
250
+ print(f"Local gateway: {config.gateway_url}")
251
+ print(f"AgentWatch cloud: {config.agentwatch_url}")
252
+ print()
253
+
254
+ # Test gateway connection first
255
+ async def test_and_run():
256
+ # Test gateway
257
+ if not await test_gateway_connection(config):
258
+ print(f"Cannot connect to local gateway at {config.gateway_url}")
259
+ print("Please make sure your Moltbot gateway is running.")
260
+ return 1
261
+
262
+ print("Local gateway connection: OK")
263
+ print()
264
+
265
+ # Start connector
266
+ connector = MoltbotConnector(config)
267
+ await connector.run()
268
+ return 0
269
+
270
+ try:
271
+ return asyncio.run(test_and_run())
272
+ except KeyboardInterrupt:
273
+ print("\nStopped by user")
274
+ return 0
275
+
276
+
277
+ def status_command(args: argparse.Namespace) -> int:
278
+ """Handle the status command."""
279
+ config = load_config()
280
+
281
+ print("AgentWatch CLI Connector Status")
282
+ print("=" * 40)
283
+
284
+ if config.is_enrolled():
285
+ print(f"Enrolled: Yes")
286
+ print(f"Agent: {config.agent_name}")
287
+ print(f"Agent ID: {config.agent_id}")
288
+ print(f"Connector ID: {config.connector_id[:8]}...")
289
+ else:
290
+ print("Enrolled: No")
291
+ print()
292
+ print("Run 'agentwatch-cli enroll --code <CODE>' to enroll")
293
+ return 0
294
+
295
+ print()
296
+ print(f"Gateway URL: {config.gateway_url}")
297
+ print(f"AgentWatch URL: {config.agentwatch_url}")
298
+
299
+ # Check gateway token
300
+ token = get_effective_gateway_token(config)
301
+ if token:
302
+ print(f"Gateway Token: {'<configured>' if config.gateway_token else '<auto-discovered>'}")
303
+ else:
304
+ print("Gateway Token: NOT FOUND")
305
+ print(" Run: agentwatch-cli config --gateway-token <TOKEN>")
306
+ print(" Or ensure ~/.openclaw/openclaw.json exists")
307
+
308
+ print()
309
+
310
+ # Test gateway connection
311
+ print("Testing gateway connection...")
312
+
313
+ async def test():
314
+ return await test_gateway_connection(config)
315
+
316
+ try:
317
+ is_healthy = asyncio.run(test())
318
+ if is_healthy:
319
+ print("Gateway Status: ONLINE")
320
+ else:
321
+ print("Gateway Status: OFFLINE or UNREACHABLE")
322
+ except Exception as e:
323
+ print(f"Gateway Status: ERROR ({e})")
324
+
325
+ return 0
326
+
327
+
328
+ def config_command(args: argparse.Namespace) -> int:
329
+ """Handle the config command."""
330
+ config = load_config()
331
+
332
+ if args.gateway_url:
333
+ config.gateway_url = args.gateway_url
334
+ print(f"Set gateway_url = {args.gateway_url}")
335
+
336
+ if args.gateway_token:
337
+ config.gateway_token = args.gateway_token
338
+ print(f"Set gateway_token = <hidden>")
339
+
340
+ if args.agentwatch_url:
341
+ config.agentwatch_url = args.agentwatch_url
342
+ print(f"Set agentwatch_url = {args.agentwatch_url}")
343
+
344
+ # Save updated config
345
+ save_config(config)
346
+ print(f"Configuration saved to {DEFAULT_CONFIG_FILE}")
347
+
348
+ return 0
349
+
350
+
351
+ def revoke_command(args: argparse.Namespace) -> int:
352
+ """Handle the revoke command (clear enrollment)."""
353
+ config = load_config()
354
+
355
+ if not config.is_enrolled():
356
+ print("Connector is not enrolled.")
357
+ return 0
358
+
359
+ # Confirm
360
+ if not args.force:
361
+ response = input(
362
+ f"This will revoke enrollment for agent '{config.agent_name}'. Continue? [y/N] "
363
+ )
364
+ if response.lower() != "y":
365
+ print("Cancelled.")
366
+ return 0
367
+
368
+ # Clear enrollment data
369
+ config.connector_id = None
370
+ config.secret = None
371
+ config.agent_id = None
372
+ config.agent_name = None
373
+
374
+ save_config(config)
375
+
376
+ print("Enrollment revoked. You will need to re-enroll to use the connector.")
377
+ return 0
378
+
379
+
380
+ def install_service_command(args: argparse.Namespace) -> int:
381
+ """Handle the install-service command."""
382
+ config = load_config()
383
+
384
+ if not config.is_enrolled():
385
+ print("Error: Connector is not enrolled.")
386
+ print("Please run 'agentwatch-cli enroll --code <CODE>' first.")
387
+ return 1
388
+
389
+ print("Installing agentwatch-cli as a system service...")
390
+ print()
391
+
392
+ success, message = install_service(user=getattr(args, 'user', None))
393
+
394
+ print(message)
395
+ return 0 if success else 1
396
+
397
+
398
+ def uninstall_service_command(args: argparse.Namespace) -> int:
399
+ """Handle the uninstall-service command."""
400
+ print("Uninstalling agentwatch-cli service...")
401
+
402
+ success, message = uninstall_service()
403
+
404
+ print(message)
405
+ return 0 if success else 1
406
+
407
+
408
+ def service_status_command(args: argparse.Namespace) -> int:
409
+ """Handle the service-status command."""
410
+ is_running, message = get_service_status()
411
+
412
+ print("AgentWatch CLI Service Status")
413
+ print("=" * 40)
414
+ print(message)
415
+
416
+ return 0
417
+
418
+
419
+ def main() -> int:
420
+ """Main CLI entry point."""
421
+ parser = argparse.ArgumentParser(
422
+ prog="agentwatch-cli",
423
+ description="Connect your local Moltbot gateway to AgentWatch cloud",
424
+ )
425
+ parser.add_argument(
426
+ "--version", action="version", version="%(prog)s 0.1.0"
427
+ )
428
+
429
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
430
+
431
+ # enroll command
432
+ enroll_parser = subparsers.add_parser(
433
+ "enroll", help="Enroll connector with an enrollment code"
434
+ )
435
+ enroll_parser.add_argument(
436
+ "--code", "-c", required=True, help="Enrollment code from AgentWatch"
437
+ )
438
+
439
+ # start command
440
+ start_parser = subparsers.add_parser(
441
+ "start", help="Start the connector"
442
+ )
443
+ start_parser.add_argument(
444
+ "--gateway-url", help="Override gateway URL"
445
+ )
446
+ start_parser.add_argument(
447
+ "--gateway-token", help="Override gateway token"
448
+ )
449
+
450
+ # status command
451
+ subparsers.add_parser(
452
+ "status", help="Show connector status"
453
+ )
454
+
455
+ # config command
456
+ config_parser = subparsers.add_parser(
457
+ "config", help="Configure connector settings"
458
+ )
459
+ config_parser.add_argument(
460
+ "--gateway-url", help="Set gateway URL"
461
+ )
462
+ config_parser.add_argument(
463
+ "--gateway-token", help="Set gateway token"
464
+ )
465
+ config_parser.add_argument(
466
+ "--agentwatch-url", help="Set AgentWatch cloud URL"
467
+ )
468
+
469
+ # revoke command
470
+ revoke_parser = subparsers.add_parser(
471
+ "revoke", help="Revoke enrollment"
472
+ )
473
+ revoke_parser.add_argument(
474
+ "--force", "-f", action="store_true", help="Skip confirmation"
475
+ )
476
+
477
+ # install-service command
478
+ install_service_parser = subparsers.add_parser(
479
+ "install-service", help="Install as a system service (auto-start on boot)"
480
+ )
481
+ install_service_parser.add_argument(
482
+ "--user", help="User to run the service as (Linux only, default: current user)"
483
+ )
484
+
485
+ # uninstall-service command
486
+ subparsers.add_parser(
487
+ "uninstall-service", help="Uninstall the system service"
488
+ )
489
+
490
+ # service-status command
491
+ subparsers.add_parser(
492
+ "service-status", help="Check the system service status"
493
+ )
494
+
495
+ args = parser.parse_args()
496
+
497
+ if args.command is None:
498
+ parser.print_help()
499
+ return 0
500
+
501
+ # Dispatch to command handler
502
+ handlers = {
503
+ "enroll": enroll_command,
504
+ "start": start_command,
505
+ "status": status_command,
506
+ "config": config_command,
507
+ "revoke": revoke_command,
508
+ "install-service": install_service_command,
509
+ "uninstall-service": uninstall_service_command,
510
+ "service-status": service_status_command,
511
+ }
512
+
513
+ handler = handlers.get(args.command)
514
+ if handler:
515
+ return handler(args)
516
+ else:
517
+ parser.print_help()
518
+ return 1
519
+
520
+
521
+ if __name__ == "__main__":
522
+ sys.exit(main())
@@ -0,0 +1,122 @@
1
+ """
2
+ Configuration management for agentwatch-cli.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, asdict
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+
12
+ # Default paths
13
+ DEFAULT_CONFIG_DIR = Path.home() / ".agentwatch-cli"
14
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
15
+ OPENCLAW_CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
16
+
17
+
18
+ @dataclass
19
+ class ConnectorConfig:
20
+ """Configuration for the agentwatch-cli connector."""
21
+
22
+ # Credentials (set after enrollment)
23
+ connector_id: Optional[str] = None
24
+ secret: Optional[str] = None
25
+ agent_id: Optional[str] = None
26
+ agent_name: Optional[str] = None
27
+
28
+ # AgentWatch cloud URL
29
+ agentwatch_url: str = "wss://agentwatch.helivan.io"
30
+
31
+ # Local OpenClaw gateway configuration (WebSocket)
32
+ gateway_url: str = "ws://127.0.0.1:18789"
33
+ gateway_token: Optional[str] = None
34
+
35
+ def is_enrolled(self) -> bool:
36
+ """Check if the connector is enrolled."""
37
+ return bool(self.connector_id and self.secret and self.agent_id)
38
+
39
+
40
+ def load_config(config_path: Optional[Path] = None) -> ConnectorConfig:
41
+ """Load configuration from file."""
42
+ path = config_path or DEFAULT_CONFIG_FILE
43
+
44
+ if not path.exists():
45
+ return ConnectorConfig()
46
+
47
+ try:
48
+ with open(path, "r") as f:
49
+ data = json.load(f)
50
+ return ConnectorConfig(**data)
51
+ except (json.JSONDecodeError, TypeError) as e:
52
+ print(f"Warning: Failed to load config from {path}: {e}")
53
+ return ConnectorConfig()
54
+
55
+
56
+ def save_config(config: ConnectorConfig, config_path: Optional[Path] = None) -> None:
57
+ """Save configuration to file."""
58
+ path = config_path or DEFAULT_CONFIG_FILE
59
+
60
+ # Create directory if it doesn't exist
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ with open(path, "w") as f:
64
+ json.dump(asdict(config), f, indent=2)
65
+
66
+ # Set restrictive permissions (only owner can read/write)
67
+ os.chmod(path, 0o600)
68
+
69
+
70
+ def discover_gateway_token() -> Optional[str]:
71
+ """
72
+ Auto-discover gateway token from openclaw.json.
73
+
74
+ Checks in order:
75
+ 1. Current directory: ./openclaw.json
76
+ 2. Home directory: ~/.openclaw/openclaw.json
77
+
78
+ Returns:
79
+ The gateway token if found, None otherwise.
80
+ """
81
+ # Check home directory first, then current directory
82
+ search_paths = [
83
+ OPENCLAW_CONFIG_PATH,
84
+ Path.cwd() / "openclaw.json",
85
+ ]
86
+
87
+ for config_path in search_paths:
88
+ if not config_path.exists():
89
+ continue
90
+
91
+ try:
92
+ with open(config_path, "r") as f:
93
+ openclaw_config = json.load(f)
94
+
95
+ # Navigate to gateway.auth.token
96
+ token = (
97
+ openclaw_config.get("gateway", {})
98
+ .get("auth", {})
99
+ .get("token")
100
+ )
101
+ if token:
102
+ return token
103
+ except (json.JSONDecodeError, KeyError, TypeError):
104
+ continue
105
+
106
+ return None
107
+
108
+
109
+ def get_effective_gateway_token(config: ConnectorConfig) -> Optional[str]:
110
+ """
111
+ Get the effective gateway token, preferring config over auto-discovery.
112
+
113
+ Args:
114
+ config: The connector configuration.
115
+
116
+ Returns:
117
+ The gateway token from config, or auto-discovered from OpenClaw config.
118
+ """
119
+ if config.gateway_token:
120
+ return config.gateway_token
121
+
122
+ return discover_gateway_token()