mcli-framework 7.1.3__py3-none-any.whl → 7.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcli-framework might be problematic. Click here for more details.

Files changed (114) hide show
  1. mcli/__init__.py +160 -0
  2. mcli/__main__.py +14 -0
  3. mcli/app/__init__.py +23 -0
  4. mcli/app/main.py +10 -0
  5. mcli/app/model/__init__.py +0 -0
  6. mcli/app/video/__init__.py +5 -0
  7. mcli/chat/__init__.py +34 -0
  8. mcli/lib/__init__.py +0 -0
  9. mcli/lib/api/__init__.py +0 -0
  10. mcli/lib/auth/__init__.py +1 -0
  11. mcli/lib/config/__init__.py +1 -0
  12. mcli/lib/custom_commands.py +424 -0
  13. mcli/lib/erd/__init__.py +25 -0
  14. mcli/lib/files/__init__.py +0 -0
  15. mcli/lib/fs/__init__.py +1 -0
  16. mcli/lib/logger/__init__.py +3 -0
  17. mcli/lib/paths.py +12 -0
  18. mcli/lib/performance/__init__.py +17 -0
  19. mcli/lib/pickles/__init__.py +1 -0
  20. mcli/lib/shell/__init__.py +0 -0
  21. mcli/lib/toml/__init__.py +1 -0
  22. mcli/lib/watcher/__init__.py +0 -0
  23. mcli/ml/__init__.py +16 -0
  24. mcli/ml/api/__init__.py +30 -0
  25. mcli/ml/api/routers/__init__.py +27 -0
  26. mcli/ml/api/schemas.py +2 -2
  27. mcli/ml/auth/__init__.py +45 -0
  28. mcli/ml/auth/models.py +2 -2
  29. mcli/ml/backtesting/__init__.py +39 -0
  30. mcli/ml/cli/__init__.py +5 -0
  31. mcli/ml/cli/main.py +1 -1
  32. mcli/ml/config/__init__.py +33 -0
  33. mcli/ml/configs/__init__.py +16 -0
  34. mcli/ml/dashboard/__init__.py +12 -0
  35. mcli/ml/dashboard/app.py +13 -13
  36. mcli/ml/dashboard/app_integrated.py +1309 -148
  37. mcli/ml/dashboard/app_supabase.py +46 -21
  38. mcli/ml/dashboard/app_training.py +14 -14
  39. mcli/ml/dashboard/components/__init__.py +7 -0
  40. mcli/ml/dashboard/components/charts.py +258 -0
  41. mcli/ml/dashboard/components/metrics.py +125 -0
  42. mcli/ml/dashboard/components/tables.py +228 -0
  43. mcli/ml/dashboard/pages/__init__.py +6 -0
  44. mcli/ml/dashboard/pages/cicd.py +382 -0
  45. mcli/ml/dashboard/pages/predictions_enhanced.py +834 -0
  46. mcli/ml/dashboard/pages/scrapers_and_logs.py +1060 -0
  47. mcli/ml/dashboard/pages/test_portfolio.py +373 -0
  48. mcli/ml/dashboard/pages/trading.py +714 -0
  49. mcli/ml/dashboard/pages/workflows.py +533 -0
  50. mcli/ml/dashboard/utils.py +154 -0
  51. mcli/ml/data_ingestion/__init__.py +39 -0
  52. mcli/ml/database/__init__.py +47 -0
  53. mcli/ml/experimentation/__init__.py +29 -0
  54. mcli/ml/features/__init__.py +39 -0
  55. mcli/ml/mlops/__init__.py +33 -0
  56. mcli/ml/models/__init__.py +94 -0
  57. mcli/ml/monitoring/__init__.py +25 -0
  58. mcli/ml/optimization/__init__.py +27 -0
  59. mcli/ml/predictions/__init__.py +5 -0
  60. mcli/ml/preprocessing/__init__.py +28 -0
  61. mcli/ml/scripts/__init__.py +1 -0
  62. mcli/ml/trading/__init__.py +60 -0
  63. mcli/ml/trading/alpaca_client.py +353 -0
  64. mcli/ml/trading/migrations.py +164 -0
  65. mcli/ml/trading/models.py +418 -0
  66. mcli/ml/trading/paper_trading.py +326 -0
  67. mcli/ml/trading/risk_management.py +370 -0
  68. mcli/ml/trading/trading_service.py +480 -0
  69. mcli/ml/training/__init__.py +10 -0
  70. mcli/ml/training/train_model.py +569 -0
  71. mcli/mygroup/__init__.py +3 -0
  72. mcli/public/__init__.py +1 -0
  73. mcli/public/commands/__init__.py +2 -0
  74. mcli/self/__init__.py +3 -0
  75. mcli/self/self_cmd.py +579 -91
  76. mcli/workflow/__init__.py +0 -0
  77. mcli/workflow/daemon/__init__.py +15 -0
  78. mcli/workflow/daemon/daemon.py +21 -3
  79. mcli/workflow/dashboard/__init__.py +5 -0
  80. mcli/workflow/docker/__init__.py +0 -0
  81. mcli/workflow/file/__init__.py +0 -0
  82. mcli/workflow/gcloud/__init__.py +1 -0
  83. mcli/workflow/git_commit/__init__.py +0 -0
  84. mcli/workflow/interview/__init__.py +0 -0
  85. mcli/workflow/politician_trading/__init__.py +4 -0
  86. mcli/workflow/politician_trading/data_sources.py +259 -1
  87. mcli/workflow/politician_trading/models.py +159 -1
  88. mcli/workflow/politician_trading/scrapers_corporate_registry.py +846 -0
  89. mcli/workflow/politician_trading/scrapers_free_sources.py +516 -0
  90. mcli/workflow/politician_trading/scrapers_third_party.py +391 -0
  91. mcli/workflow/politician_trading/seed_database.py +539 -0
  92. mcli/workflow/registry/__init__.py +0 -0
  93. mcli/workflow/repo/__init__.py +0 -0
  94. mcli/workflow/scheduler/__init__.py +25 -0
  95. mcli/workflow/search/__init__.py +0 -0
  96. mcli/workflow/sync/__init__.py +5 -0
  97. mcli/workflow/videos/__init__.py +1 -0
  98. mcli/workflow/wakatime/__init__.py +80 -0
  99. mcli/workflow/workflow.py +8 -27
  100. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/METADATA +3 -1
  101. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/RECORD +105 -29
  102. mcli/workflow/daemon/api_daemon.py +0 -800
  103. mcli/workflow/daemon/commands.py +0 -1196
  104. mcli/workflow/dashboard/dashboard_cmd.py +0 -120
  105. mcli/workflow/file/file.py +0 -100
  106. mcli/workflow/git_commit/commands.py +0 -430
  107. mcli/workflow/politician_trading/commands.py +0 -1939
  108. mcli/workflow/scheduler/commands.py +0 -493
  109. mcli/workflow/sync/sync_cmd.py +0 -437
  110. mcli/workflow/videos/videos.py +0 -242
  111. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/WHEEL +0 -0
  112. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/entry_points.txt +0 -0
  113. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/licenses/LICENSE +0 -0
  114. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/top_level.txt +0 -0
@@ -1,800 +0,0 @@
1
- import asyncio
2
- import functools
3
- import hashlib
4
- import inspect
5
- import json
6
- import logging
7
- import os
8
- import pickle
9
- import shutil
10
- import signal
11
- import sqlite3
12
- import subprocess
13
- import sys
14
- import tempfile
15
- import threading
16
- import time
17
- import uuid
18
- from dataclasses import asdict, dataclass
19
- from datetime import datetime
20
- from pathlib import Path
21
- from typing import Any, Callable, Dict, List, Optional, Union
22
-
23
- import click
24
- import psutil
25
- import requests
26
- import uvicorn
27
- from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
28
- from fastapi.middleware.cors import CORSMiddleware
29
- from pydantic import BaseModel, Field
30
-
31
- from mcli.lib.api.api import find_free_port, get_api_config
32
-
33
- # Import existing utilities
34
- from mcli.lib.logger.logger import get_logger
35
- from mcli.lib.toml.toml import read_from_toml
36
-
37
- from .process_manager import ProcessManager
38
-
39
- logger = get_logger(__name__)
40
-
41
-
42
- @dataclass
43
- class APIDaemonConfig:
44
- """Configuration for API Daemon"""
45
-
46
- enabled: bool = False
47
- host: str = "0.0.0.0"
48
- port: Optional[int] = None
49
- use_random_port: bool = True
50
- debug: bool = False
51
- auto_start: bool = False
52
- command_timeout: int = 300 # 5 minutes
53
- max_concurrent_commands: int = 10
54
- enable_command_caching: bool = True
55
- enable_command_history: bool = True
56
-
57
-
58
- class APIDaemonService:
59
- """Daemon service that listens for API commands and executes them"""
60
-
61
- def __init__(self, config_path: Optional[str] = None):
62
- self.config = self._load_config(config_path)
63
- self.app = FastAPI(
64
- title="MCLI API Daemon", description="Daemon service for MCLI commands", version="1.0.0"
65
- )
66
- self.server = None
67
- self.server_thread = None
68
- self.running = False
69
- self.command_executors = {}
70
- self.command_history = []
71
- self.active_commands = {}
72
-
73
- # Setup FastAPI app
74
- self._setup_fastapi_app()
75
-
76
- # Load command database
77
- self.db = CommandDatabase()
78
-
79
- # Initialize process manager
80
- self.process_manager = ProcessManager()
81
-
82
- logger.info(f"API Daemon initialized with config: {self.config}")
83
-
84
- def _load_config(self, config_path: Optional[str] = None) -> APIDaemonConfig:
85
- """Load configuration from TOML file"""
86
- config = APIDaemonConfig()
87
-
88
- # Try to load from config.toml files
89
- config_paths = [
90
- Path("config.toml"), # Current directory
91
- Path.home() / ".config" / "mcli" / "config.toml", # User config
92
- Path(__file__).parent.parent.parent.parent.parent / "config.toml", # Project root
93
- ]
94
-
95
- if config_path:
96
- config_paths.insert(0, Path(config_path))
97
-
98
- for path in config_paths:
99
- if path.exists():
100
- try:
101
- daemon_config = read_from_toml(str(path), "api_daemon")
102
- if daemon_config:
103
- for key, value in daemon_config.items():
104
- if hasattr(config, key):
105
- setattr(config, key, value)
106
- logger.debug(f"Loaded API daemon config from {path}")
107
- break
108
- except Exception as e:
109
- logger.debug(f"Could not load API daemon config from {path}: {e}")
110
-
111
- # Override with environment variables
112
- if os.environ.get("MCLI_API_DAEMON_ENABLED", "false").lower() in ("true", "1", "yes"):
113
- config.enabled = True
114
-
115
- if os.environ.get("MCLI_API_DAEMON_HOST"):
116
- config.host = os.environ.get("MCLI_API_DAEMON_HOST")
117
-
118
- if os.environ.get("MCLI_API_DAEMON_PORT"):
119
- config.port = int(os.environ.get("MCLI_API_DAEMON_PORT"))
120
- config.use_random_port = False
121
-
122
- if os.environ.get("MCLI_API_DAEMON_DEBUG", "false").lower() in ("true", "1", "yes"):
123
- config.debug = True
124
-
125
- if os.environ.get("MCLI_API_DAEMON_AUTO_START", "false").lower() in ("true", "1", "yes"):
126
- config.auto_start = True
127
-
128
- return config
129
-
130
- def _setup_fastapi_app(self):
131
- """Setup FastAPI application with endpoints"""
132
- # Add CORS middleware
133
- self.app.add_middleware(
134
- CORSMiddleware,
135
- allow_origins=["*"],
136
- allow_credentials=True,
137
- allow_methods=["*"],
138
- allow_headers=["*"],
139
- )
140
-
141
- # Health check endpoint
142
- @self.app.get("/health")
143
- async def health_check():
144
- return {
145
- "status": "healthy",
146
- "service": "MCLI API Daemon",
147
- "timestamp": datetime.now().isoformat(),
148
- "active_commands": len(self.active_commands),
149
- "config": asdict(self.config),
150
- }
151
-
152
- # Root endpoint
153
- @self.app.get("/")
154
- async def root():
155
- return {
156
- "service": "MCLI API Daemon",
157
- "version": "1.0.0",
158
- "status": "running" if self.running else "stopped",
159
- "endpoints": [
160
- "/health",
161
- "/status",
162
- "/commands",
163
- "/execute",
164
- "/processes",
165
- "/processes/{process_id}",
166
- "/processes/{process_id}/start",
167
- "/processes/{process_id}/stop",
168
- "/processes/{process_id}/logs",
169
- "/daemon/start",
170
- "/daemon/stop",
171
- ],
172
- }
173
-
174
- # Status endpoint
175
- @self.app.get("/status")
176
- async def status():
177
- return {
178
- "running": self.running,
179
- "active_commands": len(self.active_commands),
180
- "command_history_count": len(self.command_history),
181
- "config": asdict(self.config),
182
- }
183
-
184
- # List available commands
185
- @self.app.get("/commands")
186
- async def list_commands():
187
- commands = self.db.get_all_commands()
188
- return {"commands": [asdict(cmd) for cmd in commands], "total": len(commands)}
189
-
190
- # Execute command endpoint
191
- @self.app.post("/execute")
192
- async def execute_command(request: Request, background_tasks: BackgroundTasks):
193
- try:
194
- body = await request.json()
195
- command_id = body.get("command_id")
196
- command_name = body.get("command_name")
197
- args = body.get("args", [])
198
- timeout = body.get("timeout", self.config.command_timeout)
199
-
200
- if not command_id and not command_name:
201
- raise HTTPException(
202
- status_code=400, detail="Either command_id or command_name must be provided"
203
- )
204
-
205
- # Get command from database
206
- command = None
207
- if command_id:
208
- command = self.db.get_command(command_id)
209
- elif command_name:
210
- commands = self.db.search_commands(command_name, limit=1)
211
- if commands:
212
- command = commands[0]
213
-
214
- if not command:
215
- raise HTTPException(status_code=404, detail="Command not found")
216
-
217
- # Execute command
218
- result = await self._execute_command_async(command, args, timeout)
219
-
220
- # Record execution
221
- self.db.record_execution(
222
- command.id,
223
- "success" if result["success"] else "failed",
224
- result.get("output"),
225
- result.get("error"),
226
- result.get("execution_time_ms"),
227
- )
228
-
229
- return result
230
-
231
- except Exception as e:
232
- logger.error(f"Error executing command: {e}")
233
- raise HTTPException(status_code=500, detail=str(e))
234
-
235
- # Process management endpoints (Docker-like API)
236
- @self.app.get("/processes")
237
- async def list_processes(request: Request):
238
- """List all processes (like 'docker ps')"""
239
- all_processes = request.query_params.get("all", "false").lower() == "true"
240
- processes = self.process_manager.list_processes(all_processes=all_processes)
241
- return {"processes": processes, "total": len(processes)}
242
-
243
- @self.app.post("/processes")
244
- async def create_process(request: Request):
245
- """Create a new process container"""
246
- try:
247
- body = await request.json()
248
- name = body.get("name", "unnamed")
249
- command = body.get("command")
250
- args = body.get("args", [])
251
- working_dir = body.get("working_dir")
252
- environment = body.get("environment")
253
- auto_start = body.get("auto_start", False)
254
-
255
- if not command:
256
- raise HTTPException(status_code=400, detail="Command is required")
257
-
258
- process_id = self.process_manager.create(
259
- name=name,
260
- command=command,
261
- args=args,
262
- working_dir=working_dir,
263
- environment=environment,
264
- )
265
-
266
- if auto_start:
267
- self.process_manager.start(process_id)
268
-
269
- return {
270
- "id": process_id,
271
- "name": name,
272
- "status": "created" if not auto_start else "starting",
273
- }
274
-
275
- except Exception as e:
276
- logger.error(f"Error creating process: {e}")
277
- raise HTTPException(status_code=500, detail=str(e))
278
-
279
- @self.app.get("/processes/{process_id}")
280
- async def inspect_process(process_id: str):
281
- """Get detailed information about a process (like 'docker inspect')"""
282
- info = self.process_manager.inspect(process_id)
283
- if info is None:
284
- raise HTTPException(status_code=404, detail="Process not found")
285
- return info
286
-
287
- @self.app.post("/processes/{process_id}/start")
288
- async def start_process(process_id: str):
289
- """Start a process container"""
290
- success = self.process_manager.start(process_id)
291
- if not success:
292
- raise HTTPException(status_code=404, detail="Process not found or failed to start")
293
- return {"status": "started"}
294
-
295
- @self.app.post("/processes/{process_id}/stop")
296
- async def stop_process(process_id: str, request: Request):
297
- """Stop a process container"""
298
- body = (
299
- await request.json()
300
- if request.headers.get("content-type") == "application/json"
301
- else {}
302
- )
303
- timeout = body.get("timeout", 10)
304
-
305
- success = self.process_manager.stop(process_id, timeout)
306
- if not success:
307
- raise HTTPException(status_code=404, detail="Process not found")
308
- return {"status": "stopped"}
309
-
310
- @self.app.post("/processes/{process_id}/kill")
311
- async def kill_process(process_id: str):
312
- """Kill a process container"""
313
- success = self.process_manager.kill(process_id)
314
- if not success:
315
- raise HTTPException(status_code=404, detail="Process not found")
316
- return {"status": "killed"}
317
-
318
- @self.app.delete("/processes/{process_id}")
319
- async def remove_process(process_id: str, request: Request):
320
- """Remove a process container"""
321
- force = request.query_params.get("force", "false").lower() == "true"
322
- success = self.process_manager.remove(process_id, force)
323
- if not success:
324
- raise HTTPException(status_code=404, detail="Process not found")
325
- return {"status": "removed"}
326
-
327
- @self.app.get("/processes/{process_id}/logs")
328
- async def get_process_logs(process_id: str, request: Request):
329
- """Get logs from a process container (like 'docker logs')"""
330
- lines = request.query_params.get("lines")
331
- if lines:
332
- try:
333
- lines = int(lines)
334
- except ValueError:
335
- lines = None
336
-
337
- logs = self.process_manager.logs(process_id, lines)
338
- if logs is None:
339
- raise HTTPException(status_code=404, detail="Process not found")
340
- return logs
341
-
342
- @self.app.post("/processes/run")
343
- async def run_process(request: Request):
344
- """Create and start a process in one step (like 'docker run')"""
345
- try:
346
- body = await request.json()
347
- name = body.get("name", "unnamed")
348
- command = body.get("command")
349
- args = body.get("args", [])
350
- working_dir = body.get("working_dir")
351
- environment = body.get("environment")
352
- detach = body.get("detach", True)
353
-
354
- if not command:
355
- raise HTTPException(status_code=400, detail="Command is required")
356
-
357
- process_id = self.process_manager.run(
358
- name=name,
359
- command=command,
360
- args=args,
361
- working_dir=working_dir,
362
- environment=environment,
363
- detach=detach,
364
- )
365
-
366
- return {"id": process_id, "name": name, "status": "running"}
367
-
368
- except Exception as e:
369
- logger.error(f"Error running process: {e}")
370
- raise HTTPException(status_code=500, detail=str(e))
371
-
372
- # Daemon control endpoints
373
- @self.app.post("/daemon/start")
374
- async def start_daemon():
375
- if self.running:
376
- return {"status": "already_running"}
377
-
378
- self.start()
379
- return {"status": "started", "url": f"http://{self.config.host}:{self.config.port}"}
380
-
381
- @self.app.post("/daemon/stop")
382
- async def stop_daemon():
383
- if not self.running:
384
- return {"status": "already_stopped"}
385
-
386
- self.stop()
387
- return {"status": "stopped"}
388
-
389
- async def _execute_command_async(
390
- self, command: "Command", args: List[str], timeout: int
391
- ) -> Dict[str, Any]:
392
- """Execute command asynchronously"""
393
- command_id = str(uuid.uuid4())
394
- start_time = time.time()
395
-
396
- try:
397
- # Add to active commands
398
- self.active_commands[command_id] = {
399
- "command": command,
400
- "args": args,
401
- "start_time": start_time,
402
- "status": "running",
403
- }
404
-
405
- # Execute command in thread pool
406
- loop = asyncio.get_event_loop()
407
- result = await loop.run_in_executor(
408
- None, self._execute_command_sync, command, args, timeout
409
- )
410
-
411
- execution_time = (time.time() - start_time) * 1000
412
-
413
- # Update active commands
414
- self.active_commands[command_id]["status"] = "completed"
415
- self.active_commands[command_id]["result"] = result
416
- self.active_commands[command_id]["execution_time"] = execution_time
417
-
418
- # Add to history
419
- if self.config.enable_command_history:
420
- self.command_history.append(
421
- {
422
- "id": command_id,
423
- "command": command,
424
- "args": args,
425
- "result": result,
426
- "execution_time": execution_time,
427
- "timestamp": datetime.now().isoformat(),
428
- }
429
- )
430
-
431
- result["execution_time_ms"] = int(execution_time)
432
- return result
433
-
434
- except Exception as e:
435
- execution_time = (time.time() - start_time) * 1000
436
-
437
- # Update active commands
438
- self.active_commands[command_id]["status"] = "failed"
439
- self.active_commands[command_id]["error"] = str(e)
440
-
441
- return {"success": False, "error": str(e), "execution_time_ms": int(execution_time)}
442
- finally:
443
- # Clean up active command after timeout
444
- def cleanup():
445
- time.sleep(300) # 5 minutes
446
- if command_id in self.active_commands:
447
- del self.active_commands[command_id]
448
-
449
- threading.Thread(target=cleanup, daemon=True).start()
450
-
451
- def _execute_command_sync(
452
- self, command: "Command", args: List[str], timeout: int
453
- ) -> Dict[str, Any]:
454
- """Execute command synchronously"""
455
- executor = CommandExecutor()
456
- return executor.execute_command(command, args)
457
-
458
- def start(self):
459
- """Start the API daemon server"""
460
- if self.running:
461
- logger.warning("API daemon is already running")
462
- return
463
-
464
- # Determine port
465
- port = self.config.port
466
- if port is None and self.config.use_random_port:
467
- port = find_free_port()
468
- self.config.port = port
469
-
470
- if port is None:
471
- port = 8000
472
-
473
- # Start server in background thread
474
- def run_server():
475
- uvicorn.run(
476
- self.app,
477
- host=self.config.host,
478
- port=int(port),
479
- log_level="error", # Suppress info messages
480
- access_log=False, # Suppress access logs
481
- )
482
-
483
- self.server_thread = threading.Thread(target=run_server, daemon=True)
484
- self.server_thread.start()
485
-
486
- # Wait for server to be ready by checking health endpoint
487
- server_url = f"http://{self.config.host}:{port}"
488
- max_wait = 10 # Maximum wait time in seconds
489
- wait_interval = 0.5 # Check every 0.5 seconds
490
-
491
- for attempt in range(int(max_wait / wait_interval)):
492
- time.sleep(wait_interval)
493
- try:
494
- response = requests.get(f"{server_url}/health", timeout=1)
495
- if response.status_code == 200:
496
- self.running = True
497
- logger.info(f"API daemon started on {server_url}")
498
- return
499
- except requests.exceptions.RequestException:
500
- continue # Server not ready yet
501
-
502
- # If we get here, the server didn't start properly
503
- logger.error(f"Failed to start API daemon on {server_url} after {max_wait} seconds")
504
- self.running = False
505
-
506
- def stop(self):
507
- """Stop the API daemon server"""
508
- if not self.running:
509
- logger.warning("API daemon is not running")
510
- return
511
-
512
- self.running = False
513
- logger.info("API daemon stopped")
514
-
515
- def status(self) -> Dict[str, Any]:
516
- """Get daemon status"""
517
- return {
518
- "running": self.running,
519
- "config": asdict(self.config),
520
- "active_commands": len(self.active_commands),
521
- "command_history_count": len(self.command_history),
522
- "database_commands": len(self.db.get_all_commands()),
523
- }
524
-
525
-
526
- # Import the existing Command and CommandDatabase classes
527
- from mcli.workflow.daemon.commands import Command, CommandDatabase, CommandExecutor
528
-
529
-
530
- @click.group(name="api-daemon")
531
- def api_daemon():
532
- """API Daemon service for MCLI commands"""
533
- pass
534
-
535
-
536
- @api_daemon.command()
537
- @click.option("--config", help="Path to configuration file")
538
- @click.option("--host", help="Host to bind to")
539
- @click.option("--port", type=int, help="Port to bind to")
540
- @click.option("--debug", is_flag=True, help="Enable debug mode")
541
- @click.option("--background", "-b", is_flag=True, help="Run daemon in background")
542
- @click.option("--pid-file", help="Path to PID file for background daemon")
543
- def start(
544
- config: Optional[str],
545
- host: Optional[str],
546
- port: Optional[int],
547
- debug: bool,
548
- background: bool,
549
- pid_file: Optional[str],
550
- ):
551
- """Start the API daemon service"""
552
- daemon = APIDaemonService(config)
553
-
554
- # Override config with command line options
555
- if host:
556
- daemon.config.host = host
557
- if port:
558
- daemon.config.port = port
559
- daemon.config.use_random_port = False
560
- if debug:
561
- daemon.config.debug = debug
562
-
563
- logger.info("Starting API daemon service...")
564
-
565
- if background:
566
- # Run in background
567
- import os
568
- import sys
569
-
570
- # Fork the process
571
- try:
572
- pid = os.fork()
573
- if pid > 0:
574
- # Parent process - exit
575
- logger.info(f"API daemon started in background with PID {pid}")
576
- if pid_file:
577
- with open(pid_file, "w") as f:
578
- f.write(str(pid))
579
- logger.info(f"PID written to {pid_file}")
580
- sys.exit(0)
581
- else:
582
- # Child process - run daemon
583
- # Detach from terminal
584
- os.setsid()
585
- os.chdir("/")
586
- os.umask(0)
587
-
588
- # Redirect output to /dev/null
589
- sys.stdout.flush()
590
- sys.stderr.flush()
591
- with open("/dev/null", "r") as dev_null_r:
592
- os.dup2(dev_null_r.fileno(), sys.stdin.fileno())
593
- with open("/dev/null", "a+") as dev_null_w:
594
- os.dup2(dev_null_w.fileno(), sys.stdout.fileno())
595
- os.dup2(dev_null_w.fileno(), sys.stderr.fileno())
596
-
597
- # Start daemon
598
- daemon.start()
599
-
600
- # Keep running in background
601
- try:
602
- while daemon.running:
603
- time.sleep(1)
604
- except KeyboardInterrupt:
605
- logger.info("Received interrupt, shutting down...")
606
- daemon.stop()
607
- except OSError as e:
608
- logger.error(f"Failed to start daemon in background: {e}")
609
- sys.exit(1)
610
- else:
611
- # Run in foreground
612
- daemon.start()
613
-
614
- try:
615
- # Keep the main thread alive
616
- while daemon.running:
617
- time.sleep(1)
618
- except KeyboardInterrupt:
619
- logger.info("Received interrupt, shutting down...")
620
- daemon.stop()
621
-
622
-
623
- @api_daemon.command()
624
- @click.option("--pid-file", help="Path to PID file for background daemon")
625
- def restart(pid_file: Optional[str]):
626
- """Restart the API daemon service"""
627
- logger.info("Restarting API daemon service...")
628
-
629
- # Stop the daemon
630
- stop(pid_file)
631
-
632
- # Wait a moment for shutdown
633
- time.sleep(2)
634
-
635
- # Start the daemon again
636
- # Note: This will start in foreground mode
637
- # For background restart, user should use: start --background --pid-file <file>
638
- logger.info("Starting daemon in foreground mode...")
639
- daemon = APIDaemonService()
640
- daemon.start()
641
-
642
- try:
643
- while daemon.running:
644
- time.sleep(1)
645
- except KeyboardInterrupt:
646
- logger.info("Received interrupt, shutting down...")
647
- daemon.stop()
648
-
649
-
650
- @api_daemon.command()
651
- @click.option("--pid-file", help="Path to PID file for background daemon")
652
- def stop(pid_file: Optional[str]):
653
- """Stop the API daemon service"""
654
- # Try to stop via HTTP request first
655
- try:
656
- response = requests.post("http://localhost:8000/daemon/stop")
657
- if response.status_code == 200:
658
- logger.info("API daemon stopped successfully via HTTP")
659
- return
660
- except requests.exceptions.RequestException:
661
- logger.debug("Could not connect to API daemon via HTTP")
662
-
663
- # If HTTP stop failed, try PID file method
664
- if pid_file and Path(pid_file).exists():
665
- try:
666
- with open(pid_file, "r") as f:
667
- pid = int(f.read().strip())
668
-
669
- # Send SIGTERM to the process
670
- os.kill(pid, signal.SIGTERM)
671
- logger.info(f"Sent SIGTERM to daemon process {pid}")
672
-
673
- # Wait a moment and check if process is still running
674
- time.sleep(2)
675
- try:
676
- os.kill(pid, 0) # Check if process exists
677
- # Process still running, send SIGKILL
678
- os.kill(pid, signal.SIGKILL)
679
- logger.info(f"Sent SIGKILL to daemon process {pid}")
680
- except OSError:
681
- # Process already terminated
682
- pass
683
-
684
- # Remove PID file
685
- Path(pid_file).unlink()
686
- logger.info(f"Removed PID file {pid_file}")
687
-
688
- except (ValueError, OSError) as e:
689
- logger.error(f"Failed to stop daemon using PID file: {e}")
690
- else:
691
- logger.error("Could not stop API daemon - no PID file provided and HTTP connection failed")
692
-
693
-
694
- @api_daemon.command()
695
- @click.option("--pid-file", help="Path to PID file for background daemon")
696
- def status(pid_file: Optional[str]):
697
- """Show API daemon status"""
698
- # Check if daemon is running via HTTP
699
- try:
700
- response = requests.get("http://localhost:8000/status")
701
- if response.status_code == 200:
702
- status_data = response.json()
703
- logger.info(f"API Daemon Status:")
704
- logger.info(f" Running: {status_data['running']}")
705
- logger.info(f" Active Commands: {status_data['active_commands']}")
706
- logger.info(f" Command History: {status_data['command_history_count']}")
707
- logger.info(f" Config: {status_data['config']}")
708
- return
709
- except requests.exceptions.RequestException:
710
- logger.debug("Could not connect to API daemon via HTTP")
711
-
712
- # Check if background daemon is running via PID file
713
- if pid_file and Path(pid_file).exists():
714
- try:
715
- with open(pid_file, "r") as f:
716
- pid = int(f.read().strip())
717
-
718
- # Check if process is running
719
- try:
720
- os.kill(pid, 0) # Check if process exists
721
- logger.info(f"API Daemon Status:")
722
- logger.info(f" Running: True (PID: {pid})")
723
- logger.info(f" PID File: {pid_file}")
724
- logger.info(f" Note: Daemon is running in background mode")
725
- return
726
- except OSError:
727
- logger.info(f"API Daemon Status:")
728
- logger.info(f" Running: False")
729
- logger.info(f" Note: PID file exists but process is not running")
730
- # Remove stale PID file
731
- Path(pid_file).unlink()
732
- logger.info(f" Removed stale PID file {pid_file}")
733
- return
734
- except (ValueError, OSError) as e:
735
- logger.error(f"Failed to read PID file: {e}")
736
-
737
- logger.info("API Daemon Status:")
738
- logger.info(" Running: False")
739
- logger.info(" Note: No daemon process found")
740
-
741
-
742
- @api_daemon.command()
743
- @click.option("--command-name", help="Name of the command to execute")
744
- @click.option("--command-id", help="ID of the command to execute")
745
- @click.option("--args", "-a", multiple=True, help="Command arguments")
746
- @click.option("--timeout", type=int, help="Command timeout in seconds")
747
- def execute(
748
- command_name: Optional[str], command_id: Optional[str], args: tuple, timeout: Optional[int]
749
- ):
750
- """Execute a command via the API daemon"""
751
- if not command_name and not command_id:
752
- logger.error("Either --command-name or --command-id must be provided")
753
- return
754
-
755
- try:
756
- # Try to execute via HTTP API
757
- response = requests.post(
758
- "http://localhost:8000/execute",
759
- json={
760
- "command_name": command_name,
761
- "command_id": command_id,
762
- "args": list(args),
763
- "timeout": timeout,
764
- },
765
- )
766
-
767
- if response.status_code == 200:
768
- result = response.json()
769
- if result.get("success"):
770
- logger.info("✅ Command executed successfully")
771
- if result.get("output"):
772
- logger.info("Output:")
773
- print(result["output"])
774
- else:
775
- logger.error("❌ Command execution failed")
776
- if result.get("error"):
777
- logger.error(f"Error: {result['error']}")
778
- else:
779
- logger.error(f"Failed to execute command: {response.status_code}")
780
-
781
- except requests.exceptions.RequestException as e:
782
- logger.error(f"Could not connect to API daemon: {e}")
783
- logger.error("Make sure the API daemon is running")
784
- logger.error("Start it with: python -m mcli workflow api-daemon start")
785
-
786
-
787
- @api_daemon.command()
788
- def commands():
789
- """List available commands"""
790
- try:
791
- response = requests.get("http://localhost:8000/commands")
792
- if response.status_code == 200:
793
- data = response.json()
794
- logger.info(f"Available Commands ({data['total']}):")
795
- for cmd in data["commands"]:
796
- logger.info(f" {cmd['name']}: {cmd['description']}")
797
- else:
798
- logger.error("Failed to get commands")
799
- except requests.exceptions.RequestException:
800
- logger.error("Could not connect to API daemon")