dragon-cli 0.2.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.
dragon_cli/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Dragon CLI — chat with local LLMs (GGUF) + MCP connectors.
3
+
4
+ Features:
5
+ * ASCII dragon banner at startup
6
+ * Downloads and stores GGUF models locally (./models)
7
+ * Interactive streaming chat, 100% on CPU
8
+ * MCP (Model Context Protocol) connectors via mcp_servers.json
9
+ * Tool-calling: the model can call tools exposed by MCP servers
10
+ * SQLite-backed conversation history: list/new/resume/rename/delete/search/export
11
+ * --serve: OpenAI-compatible HTTP endpoint for the loaded model
12
+ * --profile: Show hardware profile
13
+ * --recommend: Show model recommendations
14
+ * --auto: Automatically select best model for your hardware
15
+
16
+ Install (in your venv):
17
+ pip install -e .
18
+ dragon-cli-install-llm (detects your OS/GPU, installs optimal llama-cpp-python wheel)
19
+
20
+ Usage:
21
+ dragon-cli # model menu + chat
22
+ dragon-cli --model 2 # skip the model menu
23
+ dragon-cli --list # just list models
24
+ dragon-cli --no-mcp # disable MCP
25
+ dragon-cli --resume 3 # resume saved conversation #3
26
+ dragon-cli --list-conversations # list saved conversations and exit
27
+ dragon-cli --serve --port 8000 # serve the model over HTTP instead of chatting
28
+ dragon-cli --profile # show hardware profile
29
+ dragon-cli --recommend # show model recommendations
30
+ dragon-cli --auto # auto-select best model
31
+
32
+ In-chat commands:
33
+ /help /models /tools /mcp /clear
34
+ /new /list /resume /rename /delete
35
+ /search /export /exit
36
+ """
37
+
38
+ import argparse
39
+ import asyncio
40
+ import os
41
+ import sys
42
+
43
+ from . import app
44
+ from .banner import BANNER, SUBTITLE
45
+ from .config import C_CYAN, C_RESET, C_YELLOW, DB_FILE
46
+ from .models import list_catalog
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(description="Dragon CLI — local LLM + MCP")
51
+ parser.add_argument("--list", action="store_true", help="list the model catalog and exit")
52
+ parser.add_argument("--model", type=int, help="model number from the catalog")
53
+ parser.add_argument("--no-mcp", action="store_true", help="don't connect to MCP servers")
54
+ parser.add_argument("--db", default=str(DB_FILE), help="path to the SQLite conversation database")
55
+ parser.add_argument("--resume", type=int, default=None, help="resume a saved conversation by id")
56
+ parser.add_argument("--list-conversations", action="store_true", help="list saved conversations and exit")
57
+ parser.add_argument("--serve", action="store_true",
58
+ help="serve the model over an OpenAI-compatible HTTP endpoint instead of chatting")
59
+ parser.add_argument("--port", type=int, default=8000, help="port for --serve (default: 8000)")
60
+ parser.add_argument("--host", default="127.0.0.1",
61
+ help="bind address for --serve (default: 127.0.0.1, localhost only)")
62
+ parser.add_argument("--profile", action="store_true",
63
+ help="show hardware profile and exit")
64
+ parser.add_argument("--recommend", action="store_true",
65
+ help="show model recommendations for your hardware and exit")
66
+ parser.add_argument("--priority", choices=["speed", "quality", "balanced", "memory"],
67
+ default="balanced", help="optimization priority for --recommend or --auto")
68
+ parser.add_argument("--auto", action="store_true",
69
+ help="automatically select the best model for your hardware")
70
+
71
+ # --- GPU / execution backend control ---------------------------------
72
+ gpu_group = parser.add_mutually_exclusive_group()
73
+ gpu_group.add_argument("--gpu", action="store_true",
74
+ help="force GPU acceleration (offload all layers) when a GPU is present")
75
+ gpu_group.add_argument("--cpu", action="store_true",
76
+ help="force CPU-only mode (ignore any GPU)")
77
+ parser.add_argument("--n-gpu-layers", "--ngl", dest="n_gpu_layers", type=int, default=-1,
78
+ help="number of model layers to offload to the GPU "
79
+ "(-1 = auto from hardware, 0 = CPU only, >0 = explicit count)")
80
+ return parser
81
+
82
+
83
+ def _has_action_flag(args) -> bool:
84
+ """True if a CLI flag targets a specific action (skip the fancy menu)."""
85
+ return any(
86
+ getattr(args, name, False)
87
+ for name in ("list", "profile", "recommend", "serve",
88
+ "list_conversations", "auto", "model", "resume")
89
+ )
90
+
91
+
92
+ def main() -> None:
93
+ if os.name == "nt":
94
+ os.system("") # enable ANSI colors in the Windows console
95
+
96
+ args = build_parser().parse_args()
97
+
98
+ # Single-shot, print-and-exit flags (plain banner + ANSI output).
99
+ if args.profile:
100
+ print(C_CYAN + BANNER + C_RESET)
101
+ from .hardware import print_hardware_profile
102
+ print_hardware_profile()
103
+ return
104
+
105
+ if args.recommend:
106
+ from .recommendation import recommend_model_for_hardware
107
+ recommend_model_for_hardware(priority=args.priority, show_all=True)
108
+ return
109
+
110
+ if args.list:
111
+ list_catalog()
112
+ return
113
+
114
+ # Default (no action flag): launch the fancy rich-based interactive menu.
115
+ # The menu does its own per-action dependency checks, so it shows even if
116
+ # llama-cpp-python isn't installed yet.
117
+ if not _has_action_flag(args):
118
+ from .menu import run as run_menu
119
+ run_menu(args)
120
+ return
121
+
122
+ print(C_CYAN + BANNER + C_RESET)
123
+ print(C_YELLOW + SUBTITLE + C_RESET + "\n")
124
+
125
+ required = [("llama_cpp", "llama-cpp-python"), ("huggingface_hub", "huggingface_hub")]
126
+ if args.serve:
127
+ required += [("fastapi", "fastapi"), ("starlette", "starlette"), ("uvicorn", "uvicorn")]
128
+ missing = []
129
+ for module, pip_name in required:
130
+ try:
131
+ __import__(module)
132
+ except ImportError:
133
+ missing.append(pip_name)
134
+ if missing and not args.list_conversations:
135
+ if "llama-cpp-python" in missing:
136
+ print("Missing dependency: llama-cpp-python.")
137
+ print("Run: dragon-cli-install-llm (detects your OS/GPU and installs the optimal wheel)")
138
+ missing.remove("llama-cpp-python")
139
+ if missing:
140
+ print(f"Install the remaining dependencies: pip install {' '.join(missing)}")
141
+ sys.exit(1)
142
+
143
+ asyncio.run(app.run(args))
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
dragon_cli/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Paths, colors, and other shared constants for Dragon CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ MODELS_DIR = Path("models")
6
+ MCP_CONFIG_FILE = Path("mcp_servers.json")
7
+ DB_FILE = Path("dragon_cli.db")
8
+
9
+ # Simple ANSI colors (work in modern Windows Terminal / PowerShell).
10
+ C_RESET = "\033[0m"
11
+ C_CYAN = "\033[36m"
12
+ C_YELLOW = "\033[33m"
13
+ C_GREEN = "\033[32m"
14
+ C_RED = "\033[31m"
15
+ C_DIM = "\033[2m"
16
+
17
+ EXAMPLE_MCP_CONFIG = {
18
+ "mcpServers": {
19
+ "filesystem": {
20
+ "command": "npx",
21
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
22
+ },
23
+ "fetch": {
24
+ "command": "uvx",
25
+ "args": ["--with", "mcp<1.0", "mcp-server-fetch"],
26
+ },
27
+ }
28
+ }
dragon_cli/hardware.py ADDED
@@ -0,0 +1,361 @@
1
+ """Hardware detection module for Dragon CLI.
2
+
3
+ Detects CPU, RAM, GPU, storage, and operating system to calculate
4
+ execution capacity for LLM models.
5
+ """
6
+
7
+ import platform
8
+ import shutil
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ def get_os_info() -> str:
13
+ """Return operating system information."""
14
+ system = platform.system()
15
+ release = platform.release()
16
+ version = platform.version()
17
+ machine = platform.machine()
18
+
19
+ if system == "Windows":
20
+ # Get more detailed Windows version
21
+ win_ver = platform.win32_ver()
22
+ return f"{system} {release} ({win_ver[0]}) {machine}"
23
+ elif system == "Darwin":
24
+ return f"macOS {release} ({machine})"
25
+ else:
26
+ # Linux or other
27
+ try:
28
+ with open("/etc/os-release", "r") as f:
29
+ for line in f:
30
+ if line.startswith("PRETTY_NAME="):
31
+ pretty_name = line.strip().split("=", 1)[1].strip('"')
32
+ return f"{pretty_name} {machine}"
33
+ except FileNotFoundError:
34
+ pass
35
+ return f"{system} {release} ({machine})"
36
+
37
+
38
+ def get_cpu_info() -> Dict[str, Any]:
39
+ """Return CPU information."""
40
+ import os
41
+
42
+ cpu_count = os.cpu_count() or 0
43
+ # Physical count estimation (usually half of logical for hyperthreading)
44
+ cpu_count_physical = max(1, cpu_count // 2) if cpu_count > 0 else 0
45
+
46
+ # Try to get CPU model/name
47
+ cpu_model = "Unknown"
48
+
49
+ if platform.system() == "Windows":
50
+ try:
51
+ import ctypes
52
+ # Use wmi or registry for Windows CPU info
53
+ import subprocess
54
+ result = subprocess.run(
55
+ ["wmic", "cpu", "get", "name"],
56
+ capture_output=True, text=True, timeout=5
57
+ )
58
+ lines = result.stdout.strip().split("\n")
59
+ if len(lines) >= 2:
60
+ cpu_model = lines[1].strip()
61
+ except Exception:
62
+ pass
63
+ elif platform.system() == "Darwin":
64
+ try:
65
+ import subprocess
66
+ result = subprocess.run(
67
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
68
+ capture_output=True, text=True, timeout=5
69
+ )
70
+ if result.returncode == 0:
71
+ cpu_model = result.stdout.strip()
72
+ except Exception:
73
+ pass
74
+ else:
75
+ # Linux
76
+ try:
77
+ with open("/proc/cpuinfo", "r") as f:
78
+ for line in f:
79
+ if line.startswith("model name"):
80
+ cpu_model = line.split(":", 1)[1].strip()
81
+ break
82
+ except FileNotFoundError:
83
+ pass
84
+
85
+ return {
86
+ "cpu_count": cpu_count,
87
+ "cpu_count_physical": cpu_count_physical,
88
+ "cpu_model": cpu_model,
89
+ }
90
+
91
+
92
+ def get_ram_info() -> Dict[str, float]:
93
+ """Return RAM information in GB."""
94
+ try:
95
+ import psutil
96
+ vm = psutil.virtual_memory()
97
+ ram_total_gb = vm.total / (1024 ** 3)
98
+ ram_available_gb = vm.available / (1024 ** 3)
99
+ ram_used_gb = vm.used / (1024 ** 3)
100
+ ram_percent = vm.percent
101
+ except ImportError:
102
+ # Fallback without psutil
103
+ if platform.system() == "Linux":
104
+ try:
105
+ with open("/proc/meminfo", "r") as f:
106
+ mem_info = {}
107
+ for line in f:
108
+ parts = line.split(":")
109
+ if len(parts) == 2:
110
+ key = parts[0].strip()
111
+ value_str = parts[1].strip().split()[0]
112
+ try:
113
+ mem_info[key] = int(value_str)
114
+ except ValueError:
115
+ pass
116
+
117
+ # Values are in kB
118
+ ram_total_gb = mem_info.get("MemTotal", 0) / (1024 ** 2)
119
+ ram_free_gb = mem_info.get("MemFree", 0) / (1024 ** 2)
120
+ ram_available_gb = mem_info.get("MemAvailable", ram_free_gb) / (1024 ** 2)
121
+ ram_used_gb = ram_total_gb - ram_free_gb
122
+ ram_percent = (ram_used_gb / ram_total_gb * 100) if ram_total_gb > 0 else 0
123
+ except FileNotFoundError:
124
+ ram_total_gb = -1
125
+ ram_available_gb = -1
126
+ ram_used_gb = -1
127
+ ram_percent = -1
128
+ else:
129
+ ram_total_gb = -1
130
+ ram_available_gb = -1
131
+ ram_used_gb = -1
132
+ ram_percent = -1
133
+
134
+ return {
135
+ "ram_total_gb": round(ram_total_gb, 2),
136
+ "ram_available_gb": round(ram_available_gb, 2),
137
+ "ram_used_gb": round(ram_used_gb, 2),
138
+ "ram_percent": round(ram_percent, 2),
139
+ }
140
+
141
+
142
+ def get_gpu_info() -> Dict[str, Any]:
143
+ """Return GPU information with graceful fallback."""
144
+ gpu_name = "None detected"
145
+ gpu_vram_gb = 0.0
146
+ gpu_available = False
147
+
148
+ # Try NVIDIA CUDA
149
+ try:
150
+ import ctypes
151
+ # Try to load CUDA library
152
+ cuda_lib_names = ["cuda", "nvcuda", "libcuda.so", "libcuda.so.1"]
153
+ cuda_lib = None
154
+
155
+ for lib_name in cuda_lib_names:
156
+ try:
157
+ cuda_lib = ctypes.CDLL(lib_name)
158
+ break
159
+ except OSError:
160
+ continue
161
+
162
+ if cuda_lib is not None:
163
+ # The driver API must be initialized before any query, otherwise
164
+ # cuDeviceGetCount returns CUDA_ERROR_NOT_INITIALIZED (3) and the
165
+ # GPU is wrongly reported as absent.
166
+ cuda_lib.cuInit(0)
167
+
168
+ # Check CUDA device count
169
+ device_count = ctypes.c_int()
170
+ result = cuda_lib.cuDeviceGetCount(ctypes.byref(device_count))
171
+
172
+ if result == 0 and device_count.value > 0:
173
+ gpu_available = True
174
+ # Get first device name
175
+ device = ctypes.c_int()
176
+ cuda_lib.cuDeviceGet(ctypes.byref(device), 0)
177
+
178
+ name_buffer = ctypes.create_string_buffer(256)
179
+ cuda_lib.cuDeviceGetName(name_buffer, 256, device)
180
+ gpu_name = name_buffer.value.decode("utf-8")
181
+
182
+ # Total VRAM via the driver API. cuMemGetInfo requires an
183
+ # active CUDA context, but cuDeviceTotalMem_v2 does not — so it
184
+ # works without creating/pinning a context (fall back to v1).
185
+ total_mem = ctypes.c_size_t()
186
+ for fn_name in ("cuDeviceTotalMem_v2", "cuDeviceTotalMem"):
187
+ fn = getattr(cuda_lib, fn_name, None)
188
+ if fn is not None and fn(ctypes.byref(total_mem), device) == 0:
189
+ break
190
+
191
+ gpu_vram_gb = total_mem.value / (1024 ** 3)
192
+
193
+ except Exception:
194
+ pass
195
+
196
+ # If no CUDA, try pytorch for GPU detection
197
+ if not gpu_available:
198
+ try:
199
+ import torch
200
+ if torch.cuda.is_available():
201
+ gpu_available = True
202
+ gpu_name = torch.cuda.get_device_name(0)
203
+ gpu_vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
204
+ except ImportError:
205
+ pass
206
+
207
+ # Try Apple Silicon (Metal/MPS)
208
+ if not gpu_available and platform.system() == "Darwin":
209
+ try:
210
+ import subprocess
211
+ result = subprocess.run(
212
+ ["system_profiler", "SPDisplaysDataType"],
213
+ capture_output=True, text=True, timeout=10
214
+ )
215
+ if result.returncode == 0:
216
+ output = result.stdout
217
+ if "Apple" in output:
218
+ gpu_available = True
219
+ # Extract GPU name
220
+ for line in output.split("\n"):
221
+ if "Chipset Model" in line:
222
+ gpu_name = line.split(":", 1)[1].strip()
223
+ break
224
+ # Apple Silicon shares unified memory
225
+ ram_info = get_ram_info()
226
+ gpu_vram_gb = ram_info.get("ram_total_gb", 0) * 0.75 # Estimate 75% available for GPU
227
+ except Exception:
228
+ pass
229
+
230
+ return {
231
+ "gpu_name": gpu_name,
232
+ "gpu_vram_gb": round(gpu_vram_gb, 2),
233
+ "gpu_available": gpu_available,
234
+ }
235
+
236
+
237
+ def get_disk_info(path: str = ".") -> Dict[str, float]:
238
+ """Return disk space information for the given path."""
239
+ try:
240
+ import psutil
241
+ usage = psutil.disk_usage(path)
242
+ disk_total_gb = usage.total / (1024 ** 3)
243
+ disk_free_gb = usage.free / (1024 ** 3)
244
+ disk_used_gb = usage.used / (1024 ** 3)
245
+ disk_percent = usage.percent
246
+ except ImportError:
247
+ # Fallback using shutil
248
+ total, used, free = shutil.disk_usage(path)
249
+ disk_total_gb = total / (1024 ** 3)
250
+ disk_free_gb = free / (1024 ** 3)
251
+ disk_used_gb = used / (1024 ** 3)
252
+ disk_percent = (used / total * 100) if total > 0 else 0
253
+
254
+ return {
255
+ "disk_total_gb": round(disk_total_gb, 2),
256
+ "disk_free_gb": round(disk_free_gb, 2),
257
+ "disk_used_gb": round(disk_used_gb, 2),
258
+ "disk_percent": round(disk_percent, 2),
259
+ }
260
+
261
+
262
+ def detect_hardware() -> Dict[str, Any]:
263
+ """Detect all hardware information and return a complete profile.
264
+
265
+ Returns:
266
+ Dictionary containing:
267
+ - os: Operating system string
268
+ - cpu_count: Number of logical CPU cores
269
+ - cpu_model: CPU model name
270
+ - ram_total_gb: Total RAM in GB
271
+ - ram_available_gb: Available RAM in GB
272
+ - gpu_name: GPU name if available
273
+ - gpu_vram_gb: GPU VRAM in GB
274
+ - disk_free_gb: Free disk space in GB
275
+ """
276
+ os_info = get_os_info()
277
+ cpu_info = get_cpu_info()
278
+ ram_info = get_ram_info()
279
+ gpu_info = get_gpu_info()
280
+ disk_info = get_disk_info()
281
+
282
+ return {
283
+ "os": os_info,
284
+ "cpu_count": cpu_info["cpu_count"],
285
+ "cpu_model": cpu_info["cpu_model"],
286
+ "ram_total_gb": ram_info["ram_total_gb"],
287
+ "ram_available_gb": ram_info["ram_available_gb"],
288
+ "gpu_name": gpu_info["gpu_name"],
289
+ "gpu_vram_gb": gpu_info["gpu_vram_gb"],
290
+ "gpu_available": gpu_info["gpu_available"],
291
+ "disk_free_gb": disk_info["disk_free_gb"],
292
+ }
293
+
294
+
295
+ def print_hardware_profile(profile: Optional[Dict[str, Any]] = None) -> None:
296
+ """Print hardware profile in a formatted way."""
297
+ from .config import C_CYAN, C_RESET, C_DIM, C_GREEN, C_YELLOW, C_RED
298
+
299
+ if profile is None:
300
+ profile = detect_hardware()
301
+
302
+ print(f"\n{C_CYAN}{'='*60}{C_RESET}")
303
+ print(f"{C_CYAN}HARDWARE PROFILE{C_RESET}")
304
+ print(f"{C_CYAN}{'='*60}{C_RESET}\n")
305
+
306
+ print(f"{C_GREEN}Operating System:{C_RESET}")
307
+ print(f" {profile['os']}\n")
308
+
309
+ print(f"{C_GREEN}CPU:{C_RESET}")
310
+ print(f" Model: {profile['cpu_model']}")
311
+ print(f" Cores: {profile['cpu_count']} logical\n")
312
+
313
+ print(f"{C_GREEN}Memory (RAM):{C_RESET}")
314
+ if profile['ram_total_gb'] > 0:
315
+ print(f" Total: {profile['ram_total_gb']:.2f} GB")
316
+ print(f" Available: {profile['ram_available_gb']:.2f} GB")
317
+ print(f" Usage: {profile.get('ram_percent', 0):.1f}%\n")
318
+ else:
319
+ print(f" {C_DIM}Unable to detect RAM{C_RESET}\n")
320
+
321
+ print(f"{C_GREEN}GPU:{C_RESET}")
322
+ if profile['gpu_available']:
323
+ print(f" Name: {profile['gpu_name']}")
324
+ print(f" VRAM: {profile['gpu_vram_gb']:.2f} GB{C_GREEN} [GPU acceleration possible]{C_RESET}\n")
325
+ else:
326
+ print(f" {C_DIM}No dedicated GPU detected (CPU-only mode){C_RESET}\n")
327
+
328
+ print(f"{C_GREEN}Storage:{C_RESET}")
329
+ print(f" Free Space: {profile['disk_free_gb']:.2f} GB\n")
330
+
331
+ # Capacity assessment
332
+ print(f"{C_CYAN}{'='*60}{C_RESET}")
333
+ print(f"{C_CYAN}EXECUTION CAPACITY ASSESSMENT{C_RESET}")
334
+ print(f"{C_CYAN}{'='*60}{C_RESET}\n")
335
+
336
+ ram = profile['ram_available_gb']
337
+ vram = profile['gpu_vram_gb']
338
+
339
+ if profile['gpu_available'] and vram >= 6:
340
+ print(f"{C_GREEN}✓ GPU Acceleration:{C_RESET} Available with {vram:.1f} GB VRAM")
341
+ print(f" → Can run larger models with GPU offloading\n")
342
+ elif profile['gpu_available']:
343
+ print(f"{C_YELLOW}⚠ GPU Limited:{C_RESET} Only {vram:.1f} GB VRAM available")
344
+ print(f" → Small models or partial GPU offloading recommended\n")
345
+ else:
346
+ print(f"{C_DIM}○ CPU-Only Mode:{C_RESET} No GPU detected")
347
+ print(f" → All computation will use CPU\n")
348
+
349
+ if ram < 4:
350
+ print(f"{C_RED}✗ Memory Critical:{C_RESET} Only {ram:.1f} GB available")
351
+ print(f" → Very limited model options (≤1.5B parameters)\n")
352
+ elif ram < 8:
353
+ print(f"{C_YELLOW}⚠ Memory Limited:{C_RESET} {ram:.1f} GB available")
354
+ print(f" → Models up to 7B (quantized) should work\n")
355
+ else:
356
+ print(f"{C_GREEN}✓ Memory Adequate:{C_RESET} {ram:.1f} GB available")
357
+ print(f" → Can run models up to 14B+ (quantized)\n")
358
+
359
+
360
+ if __name__ == "__main__":
361
+ print_hardware_profile()
@@ -0,0 +1,10 @@
1
+ from .manager import MCP_PRESETS, MCPManager, load_mcp_config, save_mcp_config
2
+ from .tool_call import extract_tool_call
3
+
4
+ __all__ = [
5
+ "MCP_PRESETS",
6
+ "MCPManager",
7
+ "extract_tool_call",
8
+ "load_mcp_config",
9
+ "save_mcp_config",
10
+ ]