closecode 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.
- closecode/__init__.py +4 -0
- closecode/__main__.py +6 -0
- closecode/agent/__init__.py +79 -0
- closecode/app.py +691 -0
- closecode/auth.py +22 -0
- closecode/config.py +97 -0
- closecode/llmesh/__init__.py +91 -0
- closecode/llmesh/client.py +221 -0
- closecode/llmesh/streaming.py +47 -0
- closecode/py.typed +0 -0
- closecode/sessions.py +176 -0
- closecode/ui/__init__.py +1 -0
- closecode/ui/screens/__init__.py +1 -0
- closecode/ui/screens/main.py +125 -0
- closecode/ui/screens/onboarding.py +101 -0
- closecode/ui/theme.tcss +374 -0
- closecode/ui/widgets/__init__.py +1 -0
- closecode/ui/widgets/chat.py +105 -0
- closecode/ui/widgets/composer.py +52 -0
- closecode/ui/widgets/header.py +53 -0
- closecode/ui/widgets/infopanel.py +101 -0
- closecode/ui/widgets/statusbar.py +60 -0
- closecode-0.1.0.dist-info/METADATA +404 -0
- closecode-0.1.0.dist-info/RECORD +28 -0
- closecode-0.1.0.dist-info/WHEEL +5 -0
- closecode-0.1.0.dist-info/entry_points.txt +2 -0
- closecode-0.1.0.dist-info/licenses/LICENSE +21 -0
- closecode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Close Code Composer widget — message input with send button."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Input, Button
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ComposerSubmit(Message):
|
|
11
|
+
"""Posted when the user submits a message (Enter or Send button)."""
|
|
12
|
+
def __init__(self, text: str):
|
|
13
|
+
self.text = text
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CloseCodeComposer(Widget):
|
|
18
|
+
"""Message input with Enter to send and a Send button."""
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
with Horizontal(id="composer-container"):
|
|
22
|
+
yield Input(
|
|
23
|
+
placeholder="Type a message or /help…",
|
|
24
|
+
id="composer-input",
|
|
25
|
+
)
|
|
26
|
+
yield Button("↑", id="composer-send", variant="primary")
|
|
27
|
+
|
|
28
|
+
def on_mount(self):
|
|
29
|
+
self.query_one("#composer-input", Input).focus()
|
|
30
|
+
|
|
31
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
32
|
+
"""Handle Enter key in the input."""
|
|
33
|
+
text = event.value.strip()
|
|
34
|
+
if text:
|
|
35
|
+
self.post_message(ComposerSubmit(text))
|
|
36
|
+
event.input.value = ""
|
|
37
|
+
|
|
38
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
39
|
+
"""Handle send button click."""
|
|
40
|
+
if event.button.id == "composer-send":
|
|
41
|
+
inp = self.query_one("#composer-input", Input)
|
|
42
|
+
text = inp.value.strip()
|
|
43
|
+
if text:
|
|
44
|
+
self.post_message(ComposerSubmit(text))
|
|
45
|
+
inp.value = ""
|
|
46
|
+
inp.focus()
|
|
47
|
+
|
|
48
|
+
def clear(self):
|
|
49
|
+
self.query_one("#composer-input", Input).value = ""
|
|
50
|
+
|
|
51
|
+
def set_focus(self):
|
|
52
|
+
self.query_one("#composer-input", Input).focus()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Close Code Header widget — top bar with title, model, and connection status."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from closecode.agent import AgentState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloseCodeHeader(Widget):
|
|
11
|
+
"""Top bar: Close Code title + model + connection indicator."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = ""
|
|
14
|
+
|
|
15
|
+
def __init__(self, connected: bool = False):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._connected = connected
|
|
18
|
+
|
|
19
|
+
def compose(self) -> ComposeResult:
|
|
20
|
+
with Static(id="header-bar"):
|
|
21
|
+
yield Static("⬡ Close Code", id="header-title")
|
|
22
|
+
yield Static("", id="header-model")
|
|
23
|
+
yield Static("", id="header-spacer")
|
|
24
|
+
yield Static("", id="header-connection")
|
|
25
|
+
|
|
26
|
+
def update_state(self, state: AgentState):
|
|
27
|
+
"""Update header from agent state."""
|
|
28
|
+
# Model
|
|
29
|
+
model = self.query_one("#header-model", Static)
|
|
30
|
+
if state.model:
|
|
31
|
+
name = state.model
|
|
32
|
+
if "/" in name:
|
|
33
|
+
name = name.split("/", 1)[1]
|
|
34
|
+
if ":free" in name:
|
|
35
|
+
name = name.replace(":free", " ·free")
|
|
36
|
+
model.update(f" [{name}]")
|
|
37
|
+
else:
|
|
38
|
+
model.update("")
|
|
39
|
+
|
|
40
|
+
# Connection
|
|
41
|
+
self.set_connected(state.connected)
|
|
42
|
+
|
|
43
|
+
def set_connected(self, connected: bool):
|
|
44
|
+
self._connected = connected
|
|
45
|
+
conn = self.query_one("#header-connection", Static)
|
|
46
|
+
if connected:
|
|
47
|
+
conn.update("● Connected")
|
|
48
|
+
conn.set_class(True, "status-connected")
|
|
49
|
+
conn.set_class(False, "status-disconnected")
|
|
50
|
+
else:
|
|
51
|
+
conn.update("○ Disconnected")
|
|
52
|
+
conn.set_class(False, "status-connected")
|
|
53
|
+
conn.set_class(True, "status-disconnected")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Close Code Info Panel — right sidebar showing tokens, context, and task checklist."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
from textual.containers import Vertical, VerticalScroll
|
|
7
|
+
|
|
8
|
+
from closecode.agent import AgentState, AgentStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskItem(Static):
|
|
12
|
+
"""A single task in the checklist."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CloseCodeInfoPanel(Widget):
|
|
17
|
+
"""Right sidebar: tokens, context %, model status, task checklist.
|
|
18
|
+
|
|
19
|
+
Takes 1/5 of the horizontal space (4:1 ratio with chat).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._tasks: list[tuple[str, bool]] = [] # (label, done)
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
with Vertical(id="info-panel-inner"):
|
|
28
|
+
# Stats section
|
|
29
|
+
yield Static("📊 Stats", id="info-header", classes="info-section-title")
|
|
30
|
+
yield Static("Tokens: 0", id="info-tokens", classes="info-stat")
|
|
31
|
+
yield Static("Context: 0%", id="info-context", classes="info-stat")
|
|
32
|
+
yield Static("", id="info-model-status", classes="info-stat")
|
|
33
|
+
|
|
34
|
+
# Divider
|
|
35
|
+
yield Static("─" * 20, classes="info-divider")
|
|
36
|
+
|
|
37
|
+
# Tasks section
|
|
38
|
+
yield Static("📋 Tasks", id="info-tasks-header", classes="info-section-title")
|
|
39
|
+
yield VerticalScroll(id="info-tasks-list")
|
|
40
|
+
|
|
41
|
+
def update_state(self, state: AgentState, model_status_tag: str = ""):
|
|
42
|
+
"""Update stats from agent state."""
|
|
43
|
+
try:
|
|
44
|
+
# Tokens
|
|
45
|
+
self.query_one("#info-tokens", Static).update(
|
|
46
|
+
f"Tokens: {state.context_used:,}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Context percentage
|
|
50
|
+
if state.context_total > 0:
|
|
51
|
+
pct = min(100, int(state.context_used / state.context_total * 100))
|
|
52
|
+
bar_len = 12
|
|
53
|
+
filled = int(bar_len * pct / 100)
|
|
54
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
55
|
+
color = "#22c55e" if pct < 50 else "#f59e0b" if pct < 80 else "#ef4444"
|
|
56
|
+
self.query_one("#info-context", Static).update(
|
|
57
|
+
f"Context: {pct}%\n[{color}]{bar}[/]"
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
self.query_one("#info-context", Static).update("Context: —")
|
|
61
|
+
|
|
62
|
+
# Model status tag
|
|
63
|
+
ms = self.query_one("#info-model-status", Static)
|
|
64
|
+
if model_status_tag:
|
|
65
|
+
ms.update(model_status_tag)
|
|
66
|
+
elif state.status == AgentStatus.STREAMING:
|
|
67
|
+
ms.update("[#00f5d4]● Streaming…[/]")
|
|
68
|
+
elif state.connected:
|
|
69
|
+
ms.update("[#22c55e]● Ready[/]")
|
|
70
|
+
else:
|
|
71
|
+
ms.update("[#ef4444]○ Disconnected[/]")
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def set_tasks(self, tasks: list[tuple[str, bool]]):
|
|
76
|
+
"""Set the task checklist. tasks = [(label, done), ...]"""
|
|
77
|
+
self._tasks = tasks
|
|
78
|
+
try:
|
|
79
|
+
container = self.query_one("#info-tasks-list", VerticalScroll)
|
|
80
|
+
# Clear existing
|
|
81
|
+
for child in list(container.children):
|
|
82
|
+
child.remove()
|
|
83
|
+
# Add new
|
|
84
|
+
for label, done in tasks:
|
|
85
|
+
icon = "[#22c55e]✓[/]" if done else "[#71717a]○[/]"
|
|
86
|
+
style = "info-task-done" if done else "info-task-pending"
|
|
87
|
+
container.mount(TaskItem(f"{icon} {label}", classes=f"info-task {style}"))
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def add_task(self, label: str, done: bool = False):
|
|
92
|
+
"""Add a task to the checklist."""
|
|
93
|
+
self._tasks.append((label, done))
|
|
94
|
+
self.set_tasks(self._tasks)
|
|
95
|
+
|
|
96
|
+
def mark_task_done(self, index: int):
|
|
97
|
+
"""Mark a task as done by index."""
|
|
98
|
+
if 0 <= index < len(self._tasks):
|
|
99
|
+
label, _ = self._tasks[index]
|
|
100
|
+
self._tasks[index] = (label, True)
|
|
101
|
+
self.set_tasks(self._tasks)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Close Code Status Bar widget — bottom bar with session, model, connection status."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from closecode.agent import AgentState, AgentStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloseCodeStatusBar(Widget):
|
|
11
|
+
"""Bottom status bar showing live application state."""
|
|
12
|
+
|
|
13
|
+
def compose(self) -> ComposeResult:
|
|
14
|
+
with Static(id="statusbar-content"):
|
|
15
|
+
yield Static("", id="sb-session", classes="status-item status-session")
|
|
16
|
+
yield Static("│", classes="status-item status-sep")
|
|
17
|
+
yield Static("No model", id="sb-model", classes="status-item status-model")
|
|
18
|
+
yield Static("│", classes="status-item status-sep")
|
|
19
|
+
yield Static("● Connected", id="sb-conn", classes="status-item status-connected")
|
|
20
|
+
yield Static("│", classes="status-item status-sep")
|
|
21
|
+
yield Static("", id="sb-status", classes="status-item")
|
|
22
|
+
|
|
23
|
+
def update_state(self, state: AgentState, session_title: str = ""):
|
|
24
|
+
"""Update status bar from agent state."""
|
|
25
|
+
# Session
|
|
26
|
+
session_w = self.query_one("#sb-session", Static)
|
|
27
|
+
if session_title:
|
|
28
|
+
label = session_title if len(session_title) <= 25 else session_title[:22] + "…"
|
|
29
|
+
session_w.update(f"📋 {label}")
|
|
30
|
+
else:
|
|
31
|
+
session_w.update("")
|
|
32
|
+
|
|
33
|
+
# Model
|
|
34
|
+
model_name = state.model or "No model"
|
|
35
|
+
# Shorten for display
|
|
36
|
+
if "/" in model_name:
|
|
37
|
+
model_name = model_name.split("/", 1)[1]
|
|
38
|
+
if ":free" in model_name:
|
|
39
|
+
model_name = model_name.replace(":free", "")
|
|
40
|
+
self.query_one("#sb-model", Static).update(model_name)
|
|
41
|
+
|
|
42
|
+
# Connection
|
|
43
|
+
conn = self.query_one("#sb-conn", Static)
|
|
44
|
+
if state.connected:
|
|
45
|
+
conn.update("● Connected")
|
|
46
|
+
conn.set_class(True, "status-connected")
|
|
47
|
+
conn.set_class(False, "status-disconnected")
|
|
48
|
+
else:
|
|
49
|
+
conn.update("○ Disconnected")
|
|
50
|
+
conn.set_class(False, "status-connected")
|
|
51
|
+
conn.set_class(True, "status-disconnected")
|
|
52
|
+
|
|
53
|
+
# Streaming status
|
|
54
|
+
status_widget = self.query_one("#sb-status", Static)
|
|
55
|
+
if state.status == AgentStatus.STREAMING:
|
|
56
|
+
status_widget.update("● Streaming…")
|
|
57
|
+
elif state.status == AgentStatus.FAILED:
|
|
58
|
+
status_widget.update("✗ Error")
|
|
59
|
+
else:
|
|
60
|
+
status_widget.update("")
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: closecode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Close Code — a terminal-native AI coding agent powered by the LLMesh gateway.
|
|
5
|
+
Author-email: Dhanu Gupta <dhanugupta.dev@gmail.com>
|
|
6
|
+
Maintainer-email: Dhanu Gupta <dhanugupta.dev@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/Dhanugupta0/LLMesh
|
|
9
|
+
Project-URL: Repository, https://github.com/Dhanugupta0/LLMesh
|
|
10
|
+
Project-URL: Issues, https://github.com/Dhanugupta0/LLMesh/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/Dhanugupta0/LLMesh/releases
|
|
12
|
+
Keywords: ai,llm,agent,cli,tui,terminal,coding-assistant,openai,groq,openrouter,nvidia-nim,llmesh
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: textual>=0.80.0
|
|
30
|
+
Requires-Dist: rich>=13.0.0
|
|
31
|
+
Requires-Dist: click>=8.0.0
|
|
32
|
+
Requires-Dist: httpx>=0.25.0
|
|
33
|
+
Requires-Dist: pydantic>=2.0.0
|
|
34
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
38
|
+
Requires-Dist: textual-dev>=1.5.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
<div align="center">
|
|
42
|
+
|
|
43
|
+
# ⬡ Close Code × LLMesh
|
|
44
|
+
|
|
45
|
+
**A terminal-native AI coding agent, and the universal LLM gateway behind it.**
|
|
46
|
+
|
|
47
|
+
[](https://pypi.org/project/closecode/)
|
|
48
|
+
[](https://www.python.org/downloads/)
|
|
49
|
+
[](https://opensource.org/licenses/MIT)
|
|
50
|
+
[](https://render.com/deploy)
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install closecode
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
This repository holds two halves of one system:
|
|
61
|
+
|
|
62
|
+
| | What it is | How you get it |
|
|
63
|
+
| :-- | :-- | :-- |
|
|
64
|
+
| **⬡ Close Code** | A Textual TUI that lives in your terminal. Streaming chat, local session history, model switching. | `pip install closecode` |
|
|
65
|
+
| **🌐 LLMesh** | A stateless FastAPI gateway that fans requests out to OpenAI, Groq, NVIDIA NIM and OpenRouter behind one OpenAI-compatible API. | Deploy to [Render](#-deploying-llmesh-to-render), Docker, or run locally |
|
|
66
|
+
|
|
67
|
+
Close Code never talks to a provider directly. It holds one LLMesh key, and LLMesh holds the upstream keys — so provider credentials stay on the server and your prompts stay off disk anywhere but your own machine.
|
|
68
|
+
|
|
69
|
+
> 📘 **Shipping it?** [`DEPLOYMENT.md`](DEPLOYMENT.md) is the step-by-step guide for deploying the gateway to Render + Neon and publishing the client to PyPI, with a pre-flight checklist and troubleshooting table.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## ✨ Features
|
|
74
|
+
|
|
75
|
+
### ⬡ Close Code (terminal client)
|
|
76
|
+
|
|
77
|
+
- **4:1 chat layout** — a Textual interface with message bubbles, a welcome screen, and an info sidebar.
|
|
78
|
+
- **100% local sessions** — conversations are written to `~/.closecode/sessions/` as JSON. History never leaves your machine.
|
|
79
|
+
- **Model status probing** — validates keys in the background and tags each model (`● Key Valid`, `⚠ Rate Limited`, `✗ API Key Invalid`).
|
|
80
|
+
- **Slash commands** — `/models`, `/session N`, `/rename`, `/new`, and more.
|
|
81
|
+
- **Task extraction** — parses your prompts into a live checklist in the sidebar.
|
|
82
|
+
|
|
83
|
+
### 🌐 LLMesh (gateway)
|
|
84
|
+
|
|
85
|
+
- **One API, many providers** — OpenAI-compatible `/v1/chat/completions`, `/v1/completions` and `/v1/embeddings` routed to whichever upstream serves the requested model.
|
|
86
|
+
- **Stateless by design** — prompts and responses are proxied, never persisted.
|
|
87
|
+
- **Postgres or SQLite** — point it at Neon (or any managed Postgres) with one environment variable; falls back to a local SQLite file with zero configuration.
|
|
88
|
+
- **Usage accounting** — per-key token quotas, batched writes, circuit breakers on failing upstreams.
|
|
89
|
+
- **Admin dashboard** — manage upstream keys, models and quotas from the web UI.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 🏗️ Architecture
|
|
94
|
+
|
|
95
|
+
Close Code owns the UI, context window and session storage. LLMesh owns routing, auth and accounting.
|
|
96
|
+
|
|
97
|
+
```mermaid
|
|
98
|
+
graph TD
|
|
99
|
+
subgraph Local["Your machine"]
|
|
100
|
+
O["⬡ Close Code TUI"]
|
|
101
|
+
S[("~/.closecode/sessions/<br/>local JSON")]
|
|
102
|
+
O <-->|reads / writes| S
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
subgraph Gateway["LLMesh gateway"]
|
|
106
|
+
L["FastAPI server<br/>:8087 or Render"]
|
|
107
|
+
DB[("Neon Postgres<br/>keys · models · usage")]
|
|
108
|
+
L <-->|config + quotas| DB
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
subgraph Up["Upstream providers"]
|
|
112
|
+
N["NVIDIA NIM"]
|
|
113
|
+
G["Groq"]
|
|
114
|
+
OR["OpenRouter"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
O -->|"POST /v1/chat/completions<br/>Authorization: Bearer <LLMesh key>"| L
|
|
118
|
+
L -->|"+ upstream key"| N
|
|
119
|
+
L -->|"+ upstream key"| G
|
|
120
|
+
L -->|"+ upstream key"| OR
|
|
121
|
+
|
|
122
|
+
N -->|SSE| L
|
|
123
|
+
G -->|SSE| L
|
|
124
|
+
OR -->|SSE| L
|
|
125
|
+
L -->|SSE passthrough| O
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Request lifecycle**
|
|
129
|
+
|
|
130
|
+
1. You type a prompt into Close Code.
|
|
131
|
+
2. Close Code reads the local session file, assembles the message history, and estimates token usage.
|
|
132
|
+
3. It POSTs an OpenAI-compatible payload to LLMesh.
|
|
133
|
+
4. LLMesh looks up the model, checks your key's quota, attaches the upstream provider key, and forwards.
|
|
134
|
+
5. Tokens stream back over SSE, straight through LLMesh to your terminal.
|
|
135
|
+
6. On completion, Close Code saves the new state to `~/.closecode/sessions/`, and LLMesh records token usage against your key.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 🚀 Quick start
|
|
140
|
+
|
|
141
|
+
### 1. Install the client
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pip install closecode
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> Prefer an isolated install? `pipx install closecode` works too.
|
|
148
|
+
|
|
149
|
+
### 2. Point it at a gateway
|
|
150
|
+
|
|
151
|
+
You need a running LLMesh instance and an API key from its dashboard. Either [deploy your own](#-deploying-llmesh-to-render) or [run one locally](#running-llmesh-locally).
|
|
152
|
+
|
|
153
|
+
### 3. Launch
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
closecode
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
On first run you get an onboarding screen:
|
|
160
|
+
|
|
161
|
+
1. Enter your LLMesh endpoint — `http://localhost:8087` locally, or `https://your-service.onrender.com` if deployed.
|
|
162
|
+
2. Paste your LLMesh API key.
|
|
163
|
+
3. Hit **Connect**.
|
|
164
|
+
|
|
165
|
+
Close Code fetches the model list and drops you into the chat view. Settings are remembered in `~/.closecode/config.json`.
|
|
166
|
+
|
|
167
|
+
> Upgrading from the old `ovo` package? Your `~/.ovo` directory is migrated to `~/.closecode` automatically on first launch, sessions intact. You can `pip uninstall ovo` afterwards.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 🌐 Deploying LLMesh to Render
|
|
172
|
+
|
|
173
|
+
The repo ships a [`render.yaml`](render.yaml) blueprint, so deployment is a form and two secrets. The condensed version is below; [`DEPLOYMENT.md`](DEPLOYMENT.md) has the fully expanded walkthrough.
|
|
174
|
+
|
|
175
|
+
### Step 1 — Create a Neon Postgres database
|
|
176
|
+
|
|
177
|
+
1. Sign up at [neon.tech](https://neon.tech) and create a project.
|
|
178
|
+
2. Copy the connection string from the dashboard. It looks like:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
postgresql://neondb_owner:PASSWORD@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Nothing else to do — LLMesh creates its own tables on first boot.
|
|
185
|
+
|
|
186
|
+
### Step 2 — Deploy the blueprint
|
|
187
|
+
|
|
188
|
+
1. Push this repo to GitHub.
|
|
189
|
+
2. In Render: **New → Blueprint**, and select the repo. Render reads `render.yaml`.
|
|
190
|
+
3. Fill in the variables Render marks as required:
|
|
191
|
+
|
|
192
|
+
| Variable | Value |
|
|
193
|
+
| :-- | :-- |
|
|
194
|
+
| `DATABASE_URL` | Your Neon connection string from step 1 |
|
|
195
|
+
| `ADMIN_PASSWORD_HASH` | bcrypt hash of your admin password (below) |
|
|
196
|
+
| `OPENROUTER_API_KEY` | From [openrouter.ai/keys](https://openrouter.ai/keys) |
|
|
197
|
+
| `GROQ_API_KEY` | From [console.groq.com/keys](https://console.groq.com/keys) |
|
|
198
|
+
| `NVIDIA_NIM_API_KEY` | From [build.nvidia.com](https://build.nvidia.com/) |
|
|
199
|
+
|
|
200
|
+
Generate the admin hash locally:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
python -c "import bcrypt; print(bcrypt.hashpw(b'your_password', bcrypt.gensalt()).decode())"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
4. Deploy. Render builds, runs the health check against `/healthz`, and gives you a URL.
|
|
207
|
+
|
|
208
|
+
`SESSION_SECRET_KEY` is generated by Render automatically. `DOMAIN` and `API_BASE_URL` are derived from Render's injected `RENDER_EXTERNAL_URL`, so CORS works on the generated hostname without configuration.
|
|
209
|
+
|
|
210
|
+
### Step 3 — Seed models and mint a key
|
|
211
|
+
|
|
212
|
+
1. Open `https://your-service.onrender.com/dashboard` and log in as admin.
|
|
213
|
+
2. Add your upstream servers and models, or run the seed script against the same database:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
DATABASE_URL="postgresql://..." python -m scripts.seed_servers
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
3. Register a user on the landing page to mint an LLMesh API key — that's what you paste into Close Code.
|
|
220
|
+
|
|
221
|
+
> **Free-plan note.** Render's free web services sleep after inactivity, so the first request after an idle period takes a few seconds to wake. Neon's free compute suspends too; `pool_pre_ping` and a 300s connection recycle are already configured to handle both cleanly.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## 🖥️ Running LLMesh locally
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
git clone https://github.com/Dhanugupta0/LLMesh.git
|
|
229
|
+
cd LLMesh
|
|
230
|
+
|
|
231
|
+
python -m venv .venv && source .venv/bin/activate
|
|
232
|
+
pip install -r requirements.txt
|
|
233
|
+
|
|
234
|
+
cp .env.example .env # then fill in your provider keys
|
|
235
|
+
export DEV=1
|
|
236
|
+
./start.sh
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The gateway comes up on `http://localhost:8087`. With no `DATABASE_URL` set it uses a local SQLite file at `app/database/myapi.db` — no Postgres needed for development.
|
|
240
|
+
|
|
241
|
+
### With Docker
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
cp .env.example .env # fill in your keys
|
|
245
|
+
docker compose up -d
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Add `--profile with-nginx` to bring up the bundled Nginx reverse proxy.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## ⌨️ Command reference
|
|
253
|
+
|
|
254
|
+
Slash commands inside Close Code:
|
|
255
|
+
|
|
256
|
+
| Command | Description |
|
|
257
|
+
| :--- | :--- |
|
|
258
|
+
| `/help` | Display the command reference |
|
|
259
|
+
| `/models` | List models with provider and live status |
|
|
260
|
+
| `/model <N>` | Switch to a model by index |
|
|
261
|
+
| `/sessions` | List locally saved conversations |
|
|
262
|
+
| `/session <N>` | Restore a previous conversation |
|
|
263
|
+
| `/save` | Force-save the current session |
|
|
264
|
+
| `/rename <T>` | Rename the current session |
|
|
265
|
+
| `/new` | Start a fresh session |
|
|
266
|
+
| `/clear` | Clear the display, keep the session |
|
|
267
|
+
| `/exit` | Exit Close Code |
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 🛠️ Configuration
|
|
272
|
+
|
|
273
|
+
### Close Code (client)
|
|
274
|
+
|
|
275
|
+
Config lives at `~/.closecode/config.json`, written by the onboarding screen. Environment variables override it:
|
|
276
|
+
|
|
277
|
+
| Variable | Description |
|
|
278
|
+
| :-- | :-- |
|
|
279
|
+
| `CLOSECODE_API_URL` | LLMesh endpoint (also accepts `LLMESH_API_URL`) |
|
|
280
|
+
| `CLOSECODE_API_KEY` | LLMesh API key (also accepts `LLMESH_API_KEY`) |
|
|
281
|
+
|
|
282
|
+
| Path | Contents |
|
|
283
|
+
| :-- | :-- |
|
|
284
|
+
| `~/.closecode/config.json` | Endpoint, key, model and UI preferences |
|
|
285
|
+
| `~/.closecode/sessions/` | Conversation history, one JSON file per session |
|
|
286
|
+
| `~/.closecode/logs/` | Client logs |
|
|
287
|
+
|
|
288
|
+
### LLMesh (gateway)
|
|
289
|
+
|
|
290
|
+
Full list with comments in [`.env.example`](.env.example). The ones that matter:
|
|
291
|
+
|
|
292
|
+
| Variable | Default | Description |
|
|
293
|
+
| :-- | :-- | :-- |
|
|
294
|
+
| `DATABASE_URL` | *(unset)* | Postgres connection string. Unset ⇒ SQLite. |
|
|
295
|
+
| `PGHOST` / `PGUSER` / `PGPASSWORD` / `PGDATABASE` | *(unset)* | Neon's discrete variables — used when `DATABASE_URL` is unset |
|
|
296
|
+
| `DATABASE_PATH` | `app/database/myapi.db` | SQLite file location |
|
|
297
|
+
| `SESSION_SECRET_KEY` | — | Signing key for admin sessions. Required in production. |
|
|
298
|
+
| `ADMIN_USERNAME` | `admin` | Dashboard login |
|
|
299
|
+
| `ADMIN_PASSWORD_HASH` | — | bcrypt hash of the admin password |
|
|
300
|
+
| `OPENROUTER_API_KEY` | — | Upstream provider key |
|
|
301
|
+
| `GROQ_API_KEY` | — | Upstream provider key |
|
|
302
|
+
| `NVIDIA_NIM_API_KEY` | — | Upstream provider key |
|
|
303
|
+
| `DEFAULT_LIMIT` | `1000000` | Default per-key token quota |
|
|
304
|
+
| `PORT` | `8087` | Bind port (Render sets this automatically) |
|
|
305
|
+
| `WEB_CONCURRENCY` | `4` | Gunicorn worker count |
|
|
306
|
+
|
|
307
|
+
**Postgres notes.** `postgres://` and `postgresql://` URLs are both accepted and rewritten onto `asyncpg`. libpq-only parameters (`sslmode`, `channel_binding`, ...) are stripped and translated into a real SSL context, so you can paste a Neon string verbatim. Prepared-statement caching is disabled so pooled endpoints (Neon's `-pooler` host, PgBouncer, Supavisor) work without extra configuration.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## 📡 API
|
|
312
|
+
|
|
313
|
+
LLMesh speaks the OpenAI wire format, so any OpenAI-compatible client works — not just Close Code.
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
curl https://your-service.onrender.com/v1/chat/completions \
|
|
317
|
+
-H "Authorization: Bearer $LLMESH_API_KEY" \
|
|
318
|
+
-H "Content-Type: application/json" \
|
|
319
|
+
-d '{
|
|
320
|
+
"model": "llama-3.3-70b-versatile",
|
|
321
|
+
"messages": [{"role": "user", "content": "Hello"}],
|
|
322
|
+
"stream": true
|
|
323
|
+
}'
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| Endpoint | Purpose |
|
|
327
|
+
| :-- | :-- |
|
|
328
|
+
| `GET /healthz` | Liveness probe; 503 if the database is unreachable |
|
|
329
|
+
| `GET /v1/models` | List available models |
|
|
330
|
+
| `POST /v1/chat/completions` | Chat, streaming or buffered |
|
|
331
|
+
| `POST /v1/completions` | Legacy completions |
|
|
332
|
+
| `POST /v1/embeddings` | Embeddings |
|
|
333
|
+
| `GET /dashboard` | Admin UI |
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 📦 Releasing the client to PyPI
|
|
338
|
+
|
|
339
|
+
Publishing is automated by [`.github/workflows/publish.yml`](.github/workflows/publish.yml) using PyPI Trusted Publishing, so no API token is stored anywhere. Full walkthrough in [`DEPLOYMENT.md`](DEPLOYMENT.md).
|
|
340
|
+
|
|
341
|
+
**One-time setup** — on [pypi.org/manage/account/publishing](https://pypi.org/manage/account/publishing/), add a pending publisher:
|
|
342
|
+
|
|
343
|
+
| Field | Value |
|
|
344
|
+
| :-- | :-- |
|
|
345
|
+
| PyPI project | `closecode` |
|
|
346
|
+
| Owner | `Dhanugupta0` |
|
|
347
|
+
| Repository | `LLMesh` |
|
|
348
|
+
| Workflow | `publish.yml` |
|
|
349
|
+
| Environment | `pypi` |
|
|
350
|
+
|
|
351
|
+
**Each release:**
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
# bump __version__ in closecode/__init__.py (the single source of truth —
|
|
355
|
+
# pyproject.toml reads it from there), commit, then:
|
|
356
|
+
git tag v0.1.0
|
|
357
|
+
git push origin v0.1.0
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The workflow checks the tag matches the project version, builds an sdist and wheel, verifies `theme.tcss` is bundled, and uploads. To build locally:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
pip install build twine
|
|
364
|
+
python -m build
|
|
365
|
+
twine check dist/*
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## 🗂️ Repository layout
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
.
|
|
374
|
+
├── closecode/ # ⬡ the pip-installable terminal client
|
|
375
|
+
│ ├── app.py # Textual App, slash commands, streaming loop
|
|
376
|
+
│ ├── config.py # ~/.closecode config + legacy ~/.ovo migration
|
|
377
|
+
│ ├── sessions.py # local JSON session persistence
|
|
378
|
+
│ ├── llmesh/ # HTTP client + SSE parsing
|
|
379
|
+
│ └── ui/ # screens, widgets, theme.tcss
|
|
380
|
+
│
|
|
381
|
+
├── app/ # 🌐 the LLMesh FastAPI gateway
|
|
382
|
+
│ ├── api/routes.py # OpenAI-compatible + dashboard routes
|
|
383
|
+
│ ├── core/ # lifespan, middleware, background refresh
|
|
384
|
+
│ ├── database/ # SQLAlchemy models, Postgres/SQLite engine
|
|
385
|
+
│ ├── services/ # routing, usage queue, provider clients
|
|
386
|
+
│ └── config/settings.py# environment configuration
|
|
387
|
+
│
|
|
388
|
+
├── static/ · templates/ # landing page + dashboard
|
|
389
|
+
├── scripts/ # database init and model seeding
|
|
390
|
+
├── DEPLOYMENT.md # Render + PyPI step-by-step guide
|
|
391
|
+
├── render.yaml # Render blueprint
|
|
392
|
+
├── Dockerfile · docker-compose.yml
|
|
393
|
+
└── pyproject.toml # closecode packaging
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## 📄 License
|
|
399
|
+
|
|
400
|
+
MIT — see [LICENSE](LICENSE).
|
|
401
|
+
|
|
402
|
+
<div align="center">
|
|
403
|
+
<sub>Built with 🖤 · <a href="https://pypi.org/project/closecode/">closecode on PyPI</a></sub>
|
|
404
|
+
</div>
|