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/__init__.py +5 -0
- dragon_cli/__main__.py +4 -0
- dragon_cli/_cuda.py +42 -0
- dragon_cli/app.py +123 -0
- dragon_cli/banner.py +96 -0
- dragon_cli/chat/__init__.py +4 -0
- dragon_cli/chat/commands.py +175 -0
- dragon_cli/chat/session.py +97 -0
- dragon_cli/chat/streaming.py +18 -0
- dragon_cli/cli.py +147 -0
- dragon_cli/config.py +28 -0
- dragon_cli/hardware.py +361 -0
- dragon_cli/mcp/__init__.py +10 -0
- dragon_cli/mcp/manager.py +148 -0
- dragon_cli/mcp/tool_call.py +17 -0
- dragon_cli/menu.py +1140 -0
- dragon_cli/model_hub.py +252 -0
- dragon_cli/models/__init__.py +11 -0
- dragon_cli/models/catalog.py +205 -0
- dragon_cli/models/loader.py +187 -0
- dragon_cli/recommendation.py +295 -0
- dragon_cli/runtime.py +252 -0
- dragon_cli/server.py +144 -0
- dragon_cli/setup_llm.py +197 -0
- dragon_cli/storage/__init__.py +3 -0
- dragon_cli/storage/conversations.py +134 -0
- dragon_cli/storage/database.py +32 -0
- dragon_cli/web.py +112 -0
- dragon_cli-0.2.0.dist-info/METADATA +147 -0
- dragon_cli-0.2.0.dist-info/RECORD +34 -0
- dragon_cli-0.2.0.dist-info/WHEEL +5 -0
- dragon_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dragon_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- dragon_cli-0.2.0.dist-info/top_level.txt +1 -0
dragon_cli/setup_llm.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Cross-platform installer for llama-cpp-python.
|
|
2
|
+
|
|
3
|
+
Compiling llama-cpp-python from source requires a C/C++ toolchain (Visual
|
|
4
|
+
Studio Build Tools on Windows, gcc/build-essential on Linux, Xcode command
|
|
5
|
+
line tools on macOS). This detects the current OS and GPU hardware, then
|
|
6
|
+
installs the appropriate prebuilt wheel (CPU, CUDA, or ROCm) for optimal
|
|
7
|
+
performance.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
WHEEL_INDEX_URLS = {
|
|
18
|
+
"cpu": "https://abetlen.github.io/llama-cpp-python/whl/cpu",
|
|
19
|
+
"cuda": "https://abetlen.github.io/llama-cpp-python/whl/cu124",
|
|
20
|
+
"rocm": "https://abetlen.github.io/llama-cpp-python/whl/rocm",
|
|
21
|
+
"mps": "https://abetlen.github.io/llama-cpp-python/whl/metal",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_gpu():
|
|
26
|
+
"""Detect available GPU and return GPU type.
|
|
27
|
+
|
|
28
|
+
Prefer system commands such as nvidia-smi and rocm-smi because they work
|
|
29
|
+
even in stripped-down virtual environments where torch or CUDA libraries
|
|
30
|
+
may not be installed yet.
|
|
31
|
+
"""
|
|
32
|
+
gpu_type = "cpu"
|
|
33
|
+
|
|
34
|
+
nvidia_smi = shutil.which("nvidia-smi")
|
|
35
|
+
if nvidia_smi:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=False,
|
|
41
|
+
)
|
|
42
|
+
if result.returncode == 0:
|
|
43
|
+
first_line = next((line.strip() for line in result.stdout.splitlines() if line.strip()), "")
|
|
44
|
+
if first_line:
|
|
45
|
+
gpu_name = first_line.split(",")[0].strip()
|
|
46
|
+
print(f"[dragon-cli] Detected NVIDIA GPU via nvidia-smi: {gpu_name}")
|
|
47
|
+
gpu_type = "cuda"
|
|
48
|
+
return gpu_type
|
|
49
|
+
|
|
50
|
+
if platform.system() == "Linux":
|
|
51
|
+
rocminfo = shutil.which("rocminfo")
|
|
52
|
+
if rocminfo:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[rocminfo],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
check=False,
|
|
59
|
+
)
|
|
60
|
+
if result.returncode == 0 and "Agent" in result.stdout:
|
|
61
|
+
print("[dragon-cli] Detected AMD GPU via ROCm")
|
|
62
|
+
return "rocm"
|
|
63
|
+
|
|
64
|
+
rocm_smi = shutil.which("rocm-smi")
|
|
65
|
+
if rocm_smi:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
[rocm_smi, "--showproductname"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=5,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
print("[dragon-cli] Detected AMD GPU via rocm-smi")
|
|
75
|
+
return "rocm"
|
|
76
|
+
|
|
77
|
+
if platform.system() == "Darwin":
|
|
78
|
+
system_profiler = shutil.which("system_profiler")
|
|
79
|
+
if system_profiler:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
[system_profiler, "SPDisplaysDataType"],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
timeout=10,
|
|
85
|
+
check=False,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode == 0 and "Apple" in result.stdout:
|
|
88
|
+
print("[dragon-cli] Detected Apple Silicon GPU via system_profiler")
|
|
89
|
+
return "mps"
|
|
90
|
+
|
|
91
|
+
print("[dragon-cli] No dedicated GPU detected, using CPU-only mode")
|
|
92
|
+
return gpu_type
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prompt_backend_choice(gpu_type: str) -> str:
|
|
96
|
+
"""Ask the user in English which backend to use.
|
|
97
|
+
|
|
98
|
+
CPU remains the default backend for safety, even if a GPU is detected.
|
|
99
|
+
"""
|
|
100
|
+
if gpu_type == "cpu":
|
|
101
|
+
print("[dragon-cli] No dedicated GPU detected. Defaulting to CPU backend.")
|
|
102
|
+
return "cpu"
|
|
103
|
+
|
|
104
|
+
print("[dragon-cli] GPU detected. Do you want to use a GPU-enabled wheel?")
|
|
105
|
+
print("[dragon-cli] Default backend: CPU")
|
|
106
|
+
print("[dragon-cli] Available backends: cpu, cuda, rocm, mps")
|
|
107
|
+
|
|
108
|
+
if not sys.stdin.isatty():
|
|
109
|
+
print("[dragon-cli] Non-interactive shell detected. Keeping CPU as the default backend.")
|
|
110
|
+
return "cpu"
|
|
111
|
+
|
|
112
|
+
answer = input("[dragon-cli] Choose backend [cpu]: ").strip().lower()
|
|
113
|
+
if answer in {"cpu", "cuda", "rocm", "mps"}:
|
|
114
|
+
return answer
|
|
115
|
+
if answer == "":
|
|
116
|
+
return "cpu"
|
|
117
|
+
|
|
118
|
+
print("[dragon-cli] Invalid choice. Keeping CPU as the default backend.")
|
|
119
|
+
return "cpu"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_install_command(backend: str):
|
|
123
|
+
"""Install llama-cpp-python using a prebuilt wheel backend.
|
|
124
|
+
|
|
125
|
+
CPU is the safe default; GPU backends can be selected later by the user.
|
|
126
|
+
"""
|
|
127
|
+
index_url = WHEEL_INDEX_URLS.get(backend, WHEEL_INDEX_URLS["cpu"])
|
|
128
|
+
print(f"[dragon-cli] Installing llama-cpp-python from prebuilt {backend.upper()} wheels ({index_url})...")
|
|
129
|
+
|
|
130
|
+
result = subprocess.run([
|
|
131
|
+
sys.executable, "-m", "pip", "install", "llama-cpp-python",
|
|
132
|
+
"--prefer-binary", "--extra-index-url", index_url,
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
TROUBLESHOOTING = {
|
|
139
|
+
"Windows": (
|
|
140
|
+
"Installation failed. Make sure you have Visual Studio Build Tools\n"
|
|
141
|
+
"with C++ workload installed:\n"
|
|
142
|
+
"https://visualstudio.microsoft.com/visual-cpp-build-tools/\n"
|
|
143
|
+
"Also ensure CMake is installed and in PATH."
|
|
144
|
+
),
|
|
145
|
+
"Linux": (
|
|
146
|
+
"Installation failed. Install build tools and CMake:\n"
|
|
147
|
+
" Ubuntu/Debian: sudo apt install build-essential cmake\n"
|
|
148
|
+
" Fedora/RHEL: sudo dnf install gcc gcc-c++ make cmake\n"
|
|
149
|
+
"\nFor GPU support, also install:\n"
|
|
150
|
+
" NVIDIA CUDA: https://developer.nvidia.com/cuda-downloads\n"
|
|
151
|
+
" AMD ROCm: https://rocm.docs.amd.com/"
|
|
152
|
+
),
|
|
153
|
+
"Darwin": (
|
|
154
|
+
"Installation failed. Install Xcode command line tools:\n"
|
|
155
|
+
" xcode-select --install\n"
|
|
156
|
+
"\nFor Apple Silicon, ensure you're using Python from python.org or Homebrew."
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main() -> None:
|
|
162
|
+
system = platform.system()
|
|
163
|
+
print(f"[dragon-cli] Detected {system} ({platform.machine()}), Python {platform.python_version()}")
|
|
164
|
+
print()
|
|
165
|
+
|
|
166
|
+
gpu_type = detect_gpu()
|
|
167
|
+
print()
|
|
168
|
+
|
|
169
|
+
backend = prompt_backend_choice(gpu_type)
|
|
170
|
+
print()
|
|
171
|
+
|
|
172
|
+
result = get_install_command(backend)
|
|
173
|
+
|
|
174
|
+
if result.returncode == 0:
|
|
175
|
+
print()
|
|
176
|
+
print("[dragon-cli] ✓ llama-cpp-python installed successfully!")
|
|
177
|
+
|
|
178
|
+
if backend != "cpu":
|
|
179
|
+
print(f"[dragon-cli] GPU backend selected ({backend.upper()})")
|
|
180
|
+
print("[dragon-cli] GPU-enabled wheel installed successfully")
|
|
181
|
+
else:
|
|
182
|
+
print("[dragon-cli] CPU backend selected by default")
|
|
183
|
+
print("[dragon-cli] You can choose a GPU wheel later if needed")
|
|
184
|
+
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
print("\n[dragon-cli] ✗ Installation failed.")
|
|
188
|
+
print(TROUBLESHOOTING.get(
|
|
189
|
+
system,
|
|
190
|
+
"Install a C/C++ toolchain and CMake for your platform, then retry: "
|
|
191
|
+
"pip install llama-cpp-python",
|
|
192
|
+
))
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""High-level conversation persistence API used by the chat layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .database import connect
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Conversation:
|
|
19
|
+
id: int
|
|
20
|
+
title: str
|
|
21
|
+
model_name: str | None
|
|
22
|
+
created_at: str
|
|
23
|
+
updated_at: str
|
|
24
|
+
message_count: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Message:
|
|
29
|
+
role: str
|
|
30
|
+
content: str
|
|
31
|
+
created_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConversationStore:
|
|
35
|
+
"""CRUD + search/export over the conversations/messages SQLite tables."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, db_path: Path):
|
|
38
|
+
self.conn = connect(db_path)
|
|
39
|
+
|
|
40
|
+
def create(self, title: str, model_name: str | None = None) -> int:
|
|
41
|
+
now = _now()
|
|
42
|
+
cur = self.conn.execute(
|
|
43
|
+
"INSERT INTO conversations (title, model_name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
44
|
+
(title, model_name, now, now),
|
|
45
|
+
)
|
|
46
|
+
self.conn.commit()
|
|
47
|
+
return cur.lastrowid
|
|
48
|
+
|
|
49
|
+
def list(self) -> list[Conversation]:
|
|
50
|
+
rows = self.conn.execute(
|
|
51
|
+
"""
|
|
52
|
+
SELECT c.id, c.title, c.model_name, c.created_at, c.updated_at,
|
|
53
|
+
COUNT(m.id) AS message_count
|
|
54
|
+
FROM conversations c
|
|
55
|
+
LEFT JOIN messages m ON m.conversation_id = c.id
|
|
56
|
+
GROUP BY c.id
|
|
57
|
+
ORDER BY c.updated_at DESC
|
|
58
|
+
"""
|
|
59
|
+
).fetchall()
|
|
60
|
+
return [Conversation(**dict(r)) for r in rows]
|
|
61
|
+
|
|
62
|
+
def get(self, conv_id: int) -> tuple[Conversation, list[Message]] | None:
|
|
63
|
+
row = self.conn.execute("SELECT * FROM conversations WHERE id = ?", (conv_id,)).fetchone()
|
|
64
|
+
if row is None:
|
|
65
|
+
return None
|
|
66
|
+
msg_rows = self.conn.execute(
|
|
67
|
+
"SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY id",
|
|
68
|
+
(conv_id,),
|
|
69
|
+
).fetchall()
|
|
70
|
+
messages = [Message(**dict(m)) for m in msg_rows]
|
|
71
|
+
conv = Conversation(
|
|
72
|
+
id=row["id"], title=row["title"], model_name=row["model_name"],
|
|
73
|
+
created_at=row["created_at"], updated_at=row["updated_at"],
|
|
74
|
+
message_count=len(messages),
|
|
75
|
+
)
|
|
76
|
+
return conv, messages
|
|
77
|
+
|
|
78
|
+
def append_message(self, conv_id: int, role: str, content: str) -> None:
|
|
79
|
+
now = _now()
|
|
80
|
+
self.conn.execute(
|
|
81
|
+
"INSERT INTO messages (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
|
82
|
+
(conv_id, role, content, now),
|
|
83
|
+
)
|
|
84
|
+
self.conn.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id))
|
|
85
|
+
self.conn.commit()
|
|
86
|
+
|
|
87
|
+
def rename(self, conv_id: int, title: str) -> bool:
|
|
88
|
+
cur = self.conn.execute(
|
|
89
|
+
"UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", (title, _now(), conv_id)
|
|
90
|
+
)
|
|
91
|
+
self.conn.commit()
|
|
92
|
+
return cur.rowcount > 0
|
|
93
|
+
|
|
94
|
+
def delete(self, conv_id: int) -> bool:
|
|
95
|
+
cur = self.conn.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
|
|
96
|
+
self.conn.commit()
|
|
97
|
+
return cur.rowcount > 0
|
|
98
|
+
|
|
99
|
+
def search(self, text: str) -> list[tuple[int, str, str, str]]:
|
|
100
|
+
rows = self.conn.execute(
|
|
101
|
+
"""
|
|
102
|
+
SELECT c.id, c.title, m.role, m.content
|
|
103
|
+
FROM messages m
|
|
104
|
+
JOIN conversations c ON c.id = m.conversation_id
|
|
105
|
+
WHERE m.content LIKE ?
|
|
106
|
+
ORDER BY m.created_at DESC
|
|
107
|
+
""",
|
|
108
|
+
(f"%{text}%",),
|
|
109
|
+
).fetchall()
|
|
110
|
+
return [(r["id"], r["title"], r["role"], r["content"]) for r in rows]
|
|
111
|
+
|
|
112
|
+
def export(self, conv_id: int, path: Path, fmt: str = "md") -> Path | None:
|
|
113
|
+
found = self.get(conv_id)
|
|
114
|
+
if found is None:
|
|
115
|
+
return None
|
|
116
|
+
conv, messages = found
|
|
117
|
+
if fmt == "json":
|
|
118
|
+
payload = {
|
|
119
|
+
"id": conv.id, "title": conv.title, "model_name": conv.model_name,
|
|
120
|
+
"created_at": conv.created_at, "updated_at": conv.updated_at,
|
|
121
|
+
"messages": [{"role": m.role, "content": m.content, "created_at": m.created_at} for m in messages],
|
|
122
|
+
}
|
|
123
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
124
|
+
else:
|
|
125
|
+
lines = [f"# {conv.title}", f"_model: {conv.model_name or 'unknown'} · updated: {conv.updated_at}_", ""]
|
|
126
|
+
for m in messages:
|
|
127
|
+
if m.role == "system":
|
|
128
|
+
continue
|
|
129
|
+
lines.append(f"**{m.role}:** {m.content}\n")
|
|
130
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
self.conn.close()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SQLite connection and schema management for conversation storage."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SCHEMA = """
|
|
7
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
title TEXT NOT NULL,
|
|
10
|
+
model_name TEXT,
|
|
11
|
+
created_at TEXT NOT NULL,
|
|
12
|
+
updated_at TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
18
|
+
role TEXT NOT NULL,
|
|
19
|
+
content TEXT NOT NULL,
|
|
20
|
+
created_at TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
28
|
+
conn = sqlite3.connect(str(db_path))
|
|
29
|
+
conn.row_factory = sqlite3.Row
|
|
30
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
31
|
+
conn.executescript(SCHEMA)
|
|
32
|
+
return conn
|
dragon_cli/web.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""HTML landing page and favicon for the --serve HTTP server."""
|
|
2
|
+
|
|
3
|
+
# A dragon emoji rendered as SVG text works as a favicon with zero image
|
|
4
|
+
# assets to ship, and doubles as the landing page's hero icon.
|
|
5
|
+
FAVICON_SVG = (
|
|
6
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
7
|
+
'<text x="32" y="50" font-size="52" text-anchor="middle">\U0001F409</text>'
|
|
8
|
+
"</svg>"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
_INDEX_TEMPLATE = """<!doctype html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
+
<title>Dragon CLI — __MODEL_NAME__</title>
|
|
17
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
|
18
|
+
<style>
|
|
19
|
+
:root { color-scheme: dark; }
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body {
|
|
22
|
+
margin: 0; padding: 2.5rem 1.5rem 4rem;
|
|
23
|
+
background: #0b0f14; color: #d7e0ea;
|
|
24
|
+
font-family: -apple-system, Segoe UI, Helvetica, Arial, sans-serif;
|
|
25
|
+
line-height: 1.55;
|
|
26
|
+
}
|
|
27
|
+
main { max-width: 720px; margin: 0 auto; }
|
|
28
|
+
header { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
|
|
29
|
+
.dragon { font-size: 3rem; line-height: 1; }
|
|
30
|
+
h1 { margin: 0; font-size: 1.6rem; color: #eef4fa; }
|
|
31
|
+
.subtitle { margin: 0.15rem 0 0; color: #7fe0d1; font-size: 0.95rem; }
|
|
32
|
+
.card {
|
|
33
|
+
background: #121924; border: 1px solid #212c3a; border-radius: 10px;
|
|
34
|
+
padding: 1rem 1.25rem; margin: 1.75rem 0;
|
|
35
|
+
}
|
|
36
|
+
.card h2 { margin-top: 0; font-size: 1.05rem; color: #7fe0d1; }
|
|
37
|
+
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 1rem; font-size: 0.92rem; }
|
|
38
|
+
.kv dt { color: #8a97a8; }
|
|
39
|
+
.kv dd { margin: 0; font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
|
|
40
|
+
pre {
|
|
41
|
+
background: #0d1420; border: 1px solid #1c2634; border-radius: 8px;
|
|
42
|
+
padding: 0.9rem 1rem; overflow-x: auto; font-size: 0.85rem;
|
|
43
|
+
color: #c7e8ff;
|
|
44
|
+
}
|
|
45
|
+
code { font-family: ui-monospace, Consolas, monospace; }
|
|
46
|
+
a { color: #67c9ff; }
|
|
47
|
+
footer { margin-top: 2.5rem; color: #62707f; font-size: 0.85rem; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main>
|
|
52
|
+
<header>
|
|
53
|
+
<span class="dragon">\U0001F409</span>
|
|
54
|
+
<div>
|
|
55
|
+
<h1>Dragon CLI</h1>
|
|
56
|
+
<p class="subtitle">local model server — OpenAI-compatible API</p>
|
|
57
|
+
</div>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div class="card">
|
|
61
|
+
<dl class="kv">
|
|
62
|
+
<dt>Model</dt><dd>__MODEL_NAME__</dd>
|
|
63
|
+
<dt>Base URL</dt><dd>__BASE_URL__</dd>
|
|
64
|
+
</dl>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="card">
|
|
68
|
+
<h2>Interactive API docs</h2>
|
|
69
|
+
<p><a href="/docs">Swagger UI</a> · <a href="/redoc">ReDoc</a> ·
|
|
70
|
+
<a href="/openapi.json">OpenAPI schema</a></p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="card">
|
|
74
|
+
<h2>List models</h2>
|
|
75
|
+
<pre><code>curl __BASE_URL__/v1/models</code></pre>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="card">
|
|
79
|
+
<h2>Chat completion</h2>
|
|
80
|
+
<pre><code>curl __BASE_URL__/v1/chat/completions \\
|
|
81
|
+
-H "Content-Type: application/json" \\
|
|
82
|
+
-d '{"messages": [{"role": "user", "content": "Hello!"}]}'</code></pre>
|
|
83
|
+
<p>Add <code>"stream": true</code> to the JSON body for server-sent-events streaming.</p>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="card">
|
|
87
|
+
<h2>Python (OpenAI SDK)</h2>
|
|
88
|
+
<pre><code>from openai import OpenAI
|
|
89
|
+
|
|
90
|
+
client = OpenAI(base_url="__BASE_URL_SLASH_V1__", api_key="not-needed")
|
|
91
|
+
resp = client.chat.completions.create(
|
|
92
|
+
model="__MODEL_NAME__",
|
|
93
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
94
|
+
)
|
|
95
|
+
print(resp.choices[0].message.content)</code></pre>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<footer>No MCP tool-calling on this endpoint — model only. Started with <code>dragon-cli --serve</code>.</footer>
|
|
99
|
+
</main>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_index(model_name: str, base_url: str) -> str:
|
|
106
|
+
base = base_url.rstrip("/")
|
|
107
|
+
return (
|
|
108
|
+
_INDEX_TEMPLATE
|
|
109
|
+
.replace("__MODEL_NAME__", model_name)
|
|
110
|
+
.replace("__BASE_URL_SLASH_V1__", f"{base}/v1")
|
|
111
|
+
.replace("__BASE_URL__", base)
|
|
112
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dragon-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CLI to chat with local GGUF LLMs, with MCP tool-calling and SQLite conversation history.
|
|
5
|
+
Author: JPEDROPS092
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://jpedrops092.github.io/dragon-cli-ai-local/
|
|
8
|
+
Project-URL: Repository, https://github.com/JPEDROPS092/dragon-cli-ai-local
|
|
9
|
+
Project-URL: Issues, https://github.com/JPEDROPS092/dragon-cli-ai-local/issues
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: rich
|
|
14
|
+
Requires-Dist: huggingface_hub
|
|
15
|
+
Requires-Dist: psutil
|
|
16
|
+
Requires-Dist: mcp
|
|
17
|
+
Requires-Dist: fastapi
|
|
18
|
+
Requires-Dist: starlette
|
|
19
|
+
Requires-Dist: uvicorn
|
|
20
|
+
Requires-Dist: fastapi
|
|
21
|
+
Provides-Extra: llm
|
|
22
|
+
Requires-Dist: llama-cpp-python; extra == "llm"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Dragon CLI
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
[](https://www.python.org/)
|
|
31
|
+
[](https://jpedrops092.github.io/dragon-cli-ai-local/)
|
|
32
|
+
[](https://github.com/JPEDROPS092/dragon-cli-ai-local/stargazers)
|
|
33
|
+
[](https://github.com/JPEDROPS092/dragon-cli-ai-local/issues)
|
|
34
|
+
|
|
35
|
+
Chat with local GGUF LLMs (100% CPU, via `llama-cpp-python`), with optional
|
|
36
|
+
MCP (Model Context Protocol) tool-calling and a SQLite-backed conversation
|
|
37
|
+
history.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .
|
|
43
|
+
dragon-cli-install-llm
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This exposes a `dragon-cli` command. `llama-cpp-python` is intentionally kept
|
|
47
|
+
out of the core dependencies, since it ships as C++ source on PyPI for many
|
|
48
|
+
platforms — a plain `pip install` would try to compile it locally and fail
|
|
49
|
+
without a C/C++ toolchain (Visual Studio Build Tools on Windows, gcc on
|
|
50
|
+
Linux, Xcode command line tools on macOS).
|
|
51
|
+
|
|
52
|
+
`dragon-cli-install-llm` detects your OS/architecture (Windows, Linux, or
|
|
53
|
+
macOS) and installs a prebuilt CPU wheel instead, so a compiler usually isn't
|
|
54
|
+
needed at all. If no prebuilt wheel is available for your exact
|
|
55
|
+
platform/Python combination, it prints the toolchain to install and the
|
|
56
|
+
fallback command to run.
|
|
57
|
+
|
|
58
|
+
If you already have a working C/C++ toolchain and would rather build from
|
|
59
|
+
source yourself, skip the installer and use the extra instead:
|
|
60
|
+
`pip install -e ".[llm]"`.
|
|
61
|
+
|
|
62
|
+
You can also run it without installing:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python dragon_cli.py
|
|
66
|
+
# or
|
|
67
|
+
python -m dragon_cli
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dragon-cli # model menu + chat
|
|
74
|
+
dragon-cli --model 2 # skip the model menu
|
|
75
|
+
dragon-cli --list # just list the model catalog
|
|
76
|
+
dragon-cli --no-mcp # disable MCP connectors
|
|
77
|
+
dragon-cli --resume 3 # resume saved conversation #3
|
|
78
|
+
dragon-cli --list-conversations # list saved conversations and exit
|
|
79
|
+
dragon-cli --db path/to/other.db # use a different conversation database
|
|
80
|
+
dragon-cli --serve # serve the model over HTTP instead of chatting
|
|
81
|
+
dragon-cli --serve --port 8080 # custom port (default: 8000)
|
|
82
|
+
dragon-cli --serve --host 0.0.0.0 # expose beyond localhost (e.g. to your LAN) — opt-in only
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## HTTP server (`--serve`)
|
|
86
|
+
|
|
87
|
+
`dragon-cli --serve` loads a model and exposes it over a minimal
|
|
88
|
+
OpenAI-compatible HTTP API instead of starting the interactive chat. It's
|
|
89
|
+
built on FastAPI, so interactive docs come for free:
|
|
90
|
+
|
|
91
|
+
- `GET /` — landing page with usage examples
|
|
92
|
+
- `GET /docs` — Swagger UI
|
|
93
|
+
- `GET /redoc` — ReDoc
|
|
94
|
+
- `GET /openapi.json` — raw OpenAPI schema
|
|
95
|
+
- `GET /v1/models` — lists the currently loaded model
|
|
96
|
+
- `POST /v1/chat/completions` — same shape as OpenAI's endpoint, streaming
|
|
97
|
+
(`"stream": true`, server-sent events) and non-streaming both supported,
|
|
98
|
+
request body validated (422 with a clear error on malformed input)
|
|
99
|
+
|
|
100
|
+
Point any OpenAI-compatible client/SDK at `http://127.0.0.1:8000/v1` (adjust
|
|
101
|
+
host/port to match `--host`/`--port`) and it works without extra glue code.
|
|
102
|
+
This mode does **not** expose MCP tool-calling — it's the model only,
|
|
103
|
+
kept intentionally simple and predictable. It also binds to `127.0.0.1` by
|
|
104
|
+
default (only reachable from your own machine); pass `--host 0.0.0.0`
|
|
105
|
+
explicitly if you want it reachable from other devices on your network.
|
|
106
|
+
|
|
107
|
+
## In-chat commands
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
| -------------------------- | ----------------------------------- |
|
|
111
|
+
| `/help` | show available commands |
|
|
112
|
+
| `/models` | list the model catalog |
|
|
113
|
+
| `/tools` | list connected MCP tools |
|
|
114
|
+
| `/mcp` | show MCP server connection status |
|
|
115
|
+
| `/clear` | clear the current in-memory history |
|
|
116
|
+
| `/new [title]` | start a new conversation |
|
|
117
|
+
| `/list` | list saved conversations |
|
|
118
|
+
| `/resume <id>` | resume a saved conversation |
|
|
119
|
+
| `/rename <id> <title>` | rename a saved conversation |
|
|
120
|
+
| `/delete <id>` | delete a saved conversation |
|
|
121
|
+
| `/search <text>` | search saved messages for text |
|
|
122
|
+
| `/export <id> [md\|json]` | export a conversation to a file |
|
|
123
|
+
| `/exit`, `/quit` | end the program |
|
|
124
|
+
|
|
125
|
+
Every message is autosaved to SQLite (`dragon_cli.db` by default) as soon as
|
|
126
|
+
it's sent, so nothing is lost if the process crashes.
|
|
127
|
+
|
|
128
|
+
## MCP connectors
|
|
129
|
+
|
|
130
|
+
On first run, an example `mcp_servers.json` is created in the working
|
|
131
|
+
directory. Edit it to point at your MCP servers and restart to connect.
|
|
132
|
+
|
|
133
|
+
## Project layout
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/dragon_cli/
|
|
137
|
+
├── cli.py # argparse entry point
|
|
138
|
+
├── app.py # model selection + orchestration
|
|
139
|
+
├── setup_llm.py # cross-platform llama-cpp-python installer
|
|
140
|
+
├── server.py # OpenAI-compatible HTTP server (--serve)
|
|
141
|
+
├── banner.py # ASCII banner
|
|
142
|
+
├── config.py # shared paths/colors
|
|
143
|
+
├── models/ # model catalog, download, loading
|
|
144
|
+
├── mcp/ # MCP server manager + tool-call parsing
|
|
145
|
+
├── storage/ # SQLite conversation store
|
|
146
|
+
└── chat/ # chat session, streaming, slash commands
|
|
147
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
dragon_cli/__init__.py,sha256=tSPFdYJBXGD8urB1CBZJO6WSwA-itm6EMxXp-oUf90o,87
|
|
2
|
+
dragon_cli/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
dragon_cli/_cuda.py,sha256=q2EDpKtIE1WCs1NwKqWTWzLHhw-Kc7GGCJ4A9vdX1A8,1343
|
|
4
|
+
dragon_cli/app.py,sha256=2xmllRuySzep1MkExQaIWhaZFlU2WXhaT6fR7j3eUbY,4232
|
|
5
|
+
dragon_cli/banner.py,sha256=uKDhnNTkuNZVSnmafHaMbVS0urmK6oPIpeole52me4k,3968
|
|
6
|
+
dragon_cli/cli.py,sha256=MenxJaLekcbBnuXOp7Rj4nT6kP-OYgGXnDj9JBmW93I,6430
|
|
7
|
+
dragon_cli/config.py,sha256=-4QYyPh2WOcaOyJf6XLXGsuG5KzrUeD5mcT4VygInrg,700
|
|
8
|
+
dragon_cli/hardware.py,sha256=_buxSPWLc38bOPzsxR8tiOYZTslOoj6QaAZ2cUPcmzs,13054
|
|
9
|
+
dragon_cli/menu.py,sha256=2R23yLYeYiNaPKiBzdya8pXlX_oghkNXEJ-jFO00_kM,47271
|
|
10
|
+
dragon_cli/model_hub.py,sha256=0NRR0wXJH2d9-YpAHctNEuR8cIdEUYcxEd-c5Z1LlEg,8171
|
|
11
|
+
dragon_cli/recommendation.py,sha256=9bhw2lV_LGY0dk1UNUr4Jh1UID7HvWJMorNZSTiEc2M,10845
|
|
12
|
+
dragon_cli/runtime.py,sha256=ZdmM5eZrPeMeydkZaAZFwWw2omlS3aG5iEJSZJ6Uw1o,8395
|
|
13
|
+
dragon_cli/server.py,sha256=mp235Ehwhbn2uv5leMSZR2a6UZ85-gWFyiwU_lenzVM,5666
|
|
14
|
+
dragon_cli/setup_llm.py,sha256=LVj-XFsYEW_Jje5bj2aPWDMXX8mJTI1LrtLU8Zb5FsA,6645
|
|
15
|
+
dragon_cli/web.py,sha256=KG4Ji89ex2aK_BAMettZfjKfxdXgsYyVeeno0niD_8s,3730
|
|
16
|
+
dragon_cli/chat/__init__.py,sha256=0gvbs-QAH1xwE_GXz-dEBzI-UFjgkL60PsfrvHkGHSk,148
|
|
17
|
+
dragon_cli/chat/commands.py,sha256=ec_V0HIgXnqsYGmjX7MYOqIsA8u72xDO1FplUe-NnWA,5753
|
|
18
|
+
dragon_cli/chat/session.py,sha256=HSiqPTl7fEKWyPtw6uP7A381Izt_ZhIkrIPcmgkcUCg,3840
|
|
19
|
+
dragon_cli/chat/streaming.py,sha256=P-sJ1VmRCirxpKX8crIQdtVyy_06psdqgQM9sQVmXl4,555
|
|
20
|
+
dragon_cli/mcp/__init__.py,sha256=kWwXepBx7phIfRl9qfm5TB2L6mhnwWjCVoLimy5KMdU,243
|
|
21
|
+
dragon_cli/mcp/manager.py,sha256=s-L037Go6VbHGdOW65wCZtyWYfoYH3luaswc1VYcjGc,6041
|
|
22
|
+
dragon_cli/mcp/tool_call.py,sha256=nCvkUvMo3-wJXO628UZEMtzkjt65wDwYioWAEtg9agc,433
|
|
23
|
+
dragon_cli/models/__init__.py,sha256=HtQADqyoNjL9dbk0mGoMu3AnLGoZIWYfFdwgqP5g7mE,280
|
|
24
|
+
dragon_cli/models/catalog.py,sha256=F5Jn2BdK1C2LAAtQcEpR1m3_2NpxBqms2Rb4cZ--Lv8,7701
|
|
25
|
+
dragon_cli/models/loader.py,sha256=KybLGUDKMEHwrNHgRSB9kN2rt3FzIdVSQuSLwI3tBDQ,6951
|
|
26
|
+
dragon_cli/storage/__init__.py,sha256=11JtsPqCen-9xkUKXUxeVqDtAYwUhXiTmNrDMfyVLWc,128
|
|
27
|
+
dragon_cli/storage/conversations.py,sha256=5aGfrzFYQlAtT8EiaOgMw2La_OQfR909fgoibzBwulc,4671
|
|
28
|
+
dragon_cli/storage/database.py,sha256=7Mc74FFDsYveOj3P0ZMT4sNfMH_hb93FFeSd-S_HXP8,932
|
|
29
|
+
dragon_cli-0.2.0.dist-info/licenses/LICENSE,sha256=6Fd0LBu1a_4jvyuo71J5dwl16ToLI1nYBckfIogXjcE,1068
|
|
30
|
+
dragon_cli-0.2.0.dist-info/METADATA,sha256=8wOg1XeMEJtjlET8nLuns_54OHtvqLWXNfpAao3cEeY,6409
|
|
31
|
+
dragon_cli-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
32
|
+
dragon_cli-0.2.0.dist-info/entry_points.txt,sha256=oDjJQZyVKWMIp8mCw08qcIlJSPTpQhyIWvmgjfk0ktQ,102
|
|
33
|
+
dragon_cli-0.2.0.dist-info/top_level.txt,sha256=_6Q26X07zb3vGpQJ4xnpjUdWI4h2FEN7o9nTBvUDz3Q,11
|
|
34
|
+
dragon_cli-0.2.0.dist-info/RECORD,,
|