cobalt-cli-linux 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.
- cobalt_cli_linux/__init__.py +5 -0
- cobalt_cli_linux/__main__.py +5 -0
- cobalt_cli_linux/agent.py +136 -0
- cobalt_cli_linux/cli.py +57 -0
- cobalt_cli_linux/config.py +54 -0
- cobalt_cli_linux/deepseek_client.py +51 -0
- cobalt_cli_linux/executor.py +55 -0
- cobalt_cli_linux/groq_client.py +156 -0
- cobalt_cli_linux/history.py +104 -0
- cobalt_cli_linux/tui.py +269 -0
- cobalt_cli_linux-0.1.0.dist-info/METADATA +113 -0
- cobalt_cli_linux-0.1.0.dist-info/RECORD +15 -0
- cobalt_cli_linux-0.1.0.dist-info/WHEEL +5 -0
- cobalt_cli_linux-0.1.0.dist-info/entry_points.txt +2 -0
- cobalt_cli_linux-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .config import Settings
|
|
7
|
+
from .executor import ShellExecutor
|
|
8
|
+
from .groq_client import GroqClient
|
|
9
|
+
from .history import ConversationHistory
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = """You are Cobalt, an expert Debian Linux assistant and DevOps engineer.
|
|
12
|
+
|
|
13
|
+
Your job is to help the user safely operate the system, inspect files, run commands, and maintain a persistent local conversation.
|
|
14
|
+
|
|
15
|
+
When you need to execute a shell command, read a file, or write to a file, use one of these exact formats in your response:
|
|
16
|
+
- COMMAND: <shell command>
|
|
17
|
+
- READ_FILE: <path>
|
|
18
|
+
- WRITE_FILE: <path>
|
|
19
|
+
<content>
|
|
20
|
+
|
|
21
|
+
If no tool use is required, answer normally.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CobaltAgent:
|
|
26
|
+
def __init__(self, settings: Settings | None = None, history: ConversationHistory | None = None) -> None:
|
|
27
|
+
self.settings = settings or Settings()
|
|
28
|
+
self.history = history or ConversationHistory(self.settings.history_path)
|
|
29
|
+
self.executor = ShellExecutor(self.settings.shell)
|
|
30
|
+
self.client = GroqClient(
|
|
31
|
+
api_key=self.settings.api_key,
|
|
32
|
+
model=self.settings.model,
|
|
33
|
+
base_url=self.settings.base_url,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _build_messages(self, prompt: str) -> list[dict[str, str]]:
|
|
37
|
+
user_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20)
|
|
38
|
+
user_messages.append({"role": "user", "content": prompt})
|
|
39
|
+
return user_messages
|
|
40
|
+
|
|
41
|
+
def _execute_tool(self, tool_name: str, payload: str) -> str:
|
|
42
|
+
tool_name = tool_name.strip().upper()
|
|
43
|
+
if tool_name == "COMMAND":
|
|
44
|
+
result = self.executor.run(payload)
|
|
45
|
+
output = result.stdout.strip() or ""
|
|
46
|
+
error = result.stderr.strip()
|
|
47
|
+
summary = [f"Exit code: {result.returncode}", f"Stdout: {output}", f"Stderr: {error}"]
|
|
48
|
+
return "\n".join(part for part in summary if part)
|
|
49
|
+
if tool_name == "READ_FILE":
|
|
50
|
+
path = Path(payload.strip())
|
|
51
|
+
return path.read_text(encoding="utf-8")
|
|
52
|
+
if tool_name == "WRITE_FILE":
|
|
53
|
+
if "\n" not in payload:
|
|
54
|
+
raise ValueError("WRITE_FILE requires a path and content on separate lines")
|
|
55
|
+
path_text, content = payload.split("\n", 1)
|
|
56
|
+
path = Path(path_text.strip())
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path.write_text(content, encoding="utf-8")
|
|
59
|
+
return f"Wrote {path}"
|
|
60
|
+
raise ValueError(f"Unsupported tool: {tool_name}")
|
|
61
|
+
|
|
62
|
+
def _extract_tools(self, response: str) -> list[tuple[str, str]]:
|
|
63
|
+
tools: list[tuple[str, str]] = []
|
|
64
|
+
for line in response.splitlines():
|
|
65
|
+
match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", line.strip())
|
|
66
|
+
if match:
|
|
67
|
+
tools.append((match.group(1), match.group(2)))
|
|
68
|
+
continue
|
|
69
|
+
if "WRITE_FILE:" in response:
|
|
70
|
+
match = re.search(r"WRITE_FILE:\s*(.+?)\n(.+)", response, re.DOTALL)
|
|
71
|
+
if match:
|
|
72
|
+
path = match.group(1).strip()
|
|
73
|
+
content = match.group(2)
|
|
74
|
+
tools.append(("WRITE_FILE", f"{path}\n{content}"))
|
|
75
|
+
return tools
|
|
76
|
+
|
|
77
|
+
def process(self, prompt: str) -> str:
|
|
78
|
+
if not self.settings.api_key_configured:
|
|
79
|
+
raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
|
|
80
|
+
|
|
81
|
+
self.history.add("user", prompt)
|
|
82
|
+
messages = self._build_messages(prompt)
|
|
83
|
+
response = self.client.chat_completion(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens)
|
|
84
|
+
|
|
85
|
+
tool_calls = self._extract_tools(response)
|
|
86
|
+
if not tool_calls:
|
|
87
|
+
self.history.add("assistant", response)
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
tool_results: list[str] = []
|
|
91
|
+
for tool_name, payload in tool_calls:
|
|
92
|
+
try:
|
|
93
|
+
result = self._execute_tool(tool_name, payload)
|
|
94
|
+
tool_results.append(f"{tool_name} result:\n{result}")
|
|
95
|
+
except Exception as exc: # pragma: no cover - defensive path
|
|
96
|
+
tool_results.append(f"{tool_name} error:\n{exc}")
|
|
97
|
+
|
|
98
|
+
final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
|
|
99
|
+
follow_up = self.client.chat_completion(
|
|
100
|
+
[{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}],
|
|
101
|
+
temperature=self.settings.temperature,
|
|
102
|
+
max_tokens=self.settings.max_tokens,
|
|
103
|
+
)
|
|
104
|
+
self.history.add("assistant", follow_up)
|
|
105
|
+
return follow_up
|
|
106
|
+
|
|
107
|
+
def process_stream(self, prompt: str, on_chunk=None) -> str:
|
|
108
|
+
if not self.settings.api_key_configured:
|
|
109
|
+
raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
|
|
110
|
+
|
|
111
|
+
if not self.history.messages or self.history.messages[-1].get("role") != "user":
|
|
112
|
+
self.history.add("user", prompt)
|
|
113
|
+
else:
|
|
114
|
+
self.history.messages[-1] = {"role": "user", "content": prompt}
|
|
115
|
+
self.history.save()
|
|
116
|
+
|
|
117
|
+
messages = self._build_messages(prompt)
|
|
118
|
+
buffer = ""
|
|
119
|
+
|
|
120
|
+
if self.history.messages and self.history.messages[-1].get("role") == "assistant":
|
|
121
|
+
self.history.messages[-1]["content"] = ""
|
|
122
|
+
self.history.save()
|
|
123
|
+
else:
|
|
124
|
+
self.history.add("assistant", "")
|
|
125
|
+
|
|
126
|
+
for chunk in self.client.chat_completion_stream(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens):
|
|
127
|
+
buffer += chunk
|
|
128
|
+
if on_chunk is not None:
|
|
129
|
+
on_chunk(chunk)
|
|
130
|
+
|
|
131
|
+
if self.history.messages and self.history.messages[-1].get("role") == "assistant":
|
|
132
|
+
self.history.messages[-1]["content"] = buffer
|
|
133
|
+
self.history.save()
|
|
134
|
+
else:
|
|
135
|
+
self.history.add("assistant", buffer)
|
|
136
|
+
return buffer
|
cobalt_cli_linux/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .agent import CobaltAgent
|
|
8
|
+
from .config import Settings
|
|
9
|
+
from .tui import CobaltTUI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(description="Cobalt CLI - Debian Linux Groq agent")
|
|
14
|
+
parser.add_argument("prompt", nargs="*", help="Prompt or question to send to the assistant")
|
|
15
|
+
parser.add_argument("--interactive", "-i", action="store_true", help="Launch the full-screen interactive interface")
|
|
16
|
+
parser.add_argument("--api-key", dest="api_key", help="Groq API key; defaults to the built-in package key unless GROQ_API_KEY is set.")
|
|
17
|
+
parser.add_argument("--model", default=None, help="Groq model to use (default: auto, picks the first available model)")
|
|
18
|
+
parser.add_argument("--base-url", default=None, help="Groq API base URL")
|
|
19
|
+
parser.add_argument("--history-path", default=None, help="Path to the conversation history JSON file")
|
|
20
|
+
parser.add_argument("--shell", default=None, help="Shell executable used for commands")
|
|
21
|
+
parser.add_argument("--temperature", type=float, default=None, help="Sampling temperature for model responses")
|
|
22
|
+
parser.add_argument("--max-tokens", type=int, default=None, help="Maximum number of tokens per response")
|
|
23
|
+
return parser
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
args = build_parser().parse_args(argv)
|
|
28
|
+
settings = Settings(
|
|
29
|
+
api_key=args.api_key or os.getenv("GROQ_API_KEY"),
|
|
30
|
+
model=args.model or os.getenv("GROQ_MODEL", "auto"),
|
|
31
|
+
base_url=args.base_url or os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1"),
|
|
32
|
+
history_path=args.history_path or os.getenv("COBALT_HISTORY_PATH", "~/.cobalt/history.json"),
|
|
33
|
+
shell=args.shell or os.getenv("SHELL", "/bin/bash"),
|
|
34
|
+
temperature=args.temperature if args.temperature is not None else 0.2,
|
|
35
|
+
max_tokens=args.max_tokens if args.max_tokens is not None else 1024,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if args.interactive:
|
|
39
|
+
return CobaltTUI(settings).run()
|
|
40
|
+
|
|
41
|
+
prompt = " ".join(args.prompt)
|
|
42
|
+
if not prompt:
|
|
43
|
+
print("Please provide a prompt or use --interactive for the full-screen UI.", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
agent = CobaltAgent(settings=settings)
|
|
48
|
+
response = agent.process(prompt)
|
|
49
|
+
print(response)
|
|
50
|
+
return 0
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
|
|
10
|
+
DEFAULT_API_KEY = "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"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_env_file() -> None:
|
|
14
|
+
for candidate in (".env", str(Path.home() / ".cobalt" / ".env"), str(Path.home() / ".env")):
|
|
15
|
+
path = Path(candidate).expanduser()
|
|
16
|
+
if not path.exists():
|
|
17
|
+
continue
|
|
18
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
19
|
+
line = raw_line.strip()
|
|
20
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
21
|
+
continue
|
|
22
|
+
key, value = [part.strip() for part in line.split("=", 1)]
|
|
23
|
+
os.environ.setdefault(key, value.strip('"\''))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_load_env_file()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Settings:
|
|
31
|
+
api_key: str | None = None
|
|
32
|
+
model: str = DEFAULT_MODEL
|
|
33
|
+
base_url: str = DEFAULT_BASE_URL
|
|
34
|
+
history_path: str | Path = "~/.cobalt/history.json"
|
|
35
|
+
shell: str = "/bin/bash"
|
|
36
|
+
temperature: float = 0.2
|
|
37
|
+
max_tokens: int = 1024
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.api_key is None:
|
|
41
|
+
self.api_key = os.getenv("GROQ_API_KEY", DEFAULT_API_KEY)
|
|
42
|
+
if not self.model:
|
|
43
|
+
self.model = os.getenv("GROQ_MODEL", DEFAULT_MODEL)
|
|
44
|
+
if not self.base_url:
|
|
45
|
+
self.base_url = os.getenv("GROQ_BASE_URL", DEFAULT_BASE_URL)
|
|
46
|
+
if self.history_path is None:
|
|
47
|
+
self.history_path = os.getenv("COBALT_HISTORY_PATH", str(Path.home() / ".cobalt" / "history.json"))
|
|
48
|
+
self.history_path = Path(self.history_path).expanduser()
|
|
49
|
+
if not self.shell:
|
|
50
|
+
self.shell = os.getenv("SHELL", "/bin/bash")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def api_key_configured(self) -> bool:
|
|
54
|
+
return bool(self.api_key)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeepSeekAPIError(RuntimeError):
|
|
10
|
+
"""Raised when the DeepSeek API rejects a request."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeepSeekClient:
|
|
14
|
+
def __init__(self, api_key: str, model: str = "deepseek-chat", base_url: str = "https://api.deepseek.com") -> None:
|
|
15
|
+
if not api_key:
|
|
16
|
+
raise ValueError("DeepSeek API key is required")
|
|
17
|
+
self.api_key = api_key
|
|
18
|
+
self.model = model
|
|
19
|
+
self.base_url = base_url.rstrip("/")
|
|
20
|
+
|
|
21
|
+
def chat_completion(self, messages: list[dict[str, str]], temperature: float = 0.2, max_tokens: int = 1024) -> str:
|
|
22
|
+
payload = {
|
|
23
|
+
"model": self.model,
|
|
24
|
+
"messages": messages,
|
|
25
|
+
"temperature": temperature,
|
|
26
|
+
"max_tokens": max_tokens,
|
|
27
|
+
}
|
|
28
|
+
headers = {
|
|
29
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
with httpx.Client(timeout=60.0) as client:
|
|
34
|
+
response = client.post(f"{self.base_url}/v1/chat/completions", headers=headers, json=payload)
|
|
35
|
+
|
|
36
|
+
if response.status_code != 200:
|
|
37
|
+
try:
|
|
38
|
+
detail = response.json()
|
|
39
|
+
except ValueError:
|
|
40
|
+
detail = response.text
|
|
41
|
+
raise DeepSeekAPIError(f"DeepSeek API request failed ({response.status_code}): {detail}")
|
|
42
|
+
|
|
43
|
+
data = response.json()
|
|
44
|
+
choices = data.get("choices", [])
|
|
45
|
+
if not choices:
|
|
46
|
+
raise DeepSeekAPIError("DeepSeek API returned no choices")
|
|
47
|
+
message = choices[0].get("message", {})
|
|
48
|
+
content = message.get("content", "")
|
|
49
|
+
if not isinstance(content, str):
|
|
50
|
+
return json.dumps(content)
|
|
51
|
+
return content.strip()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CommandResult:
|
|
12
|
+
command: str
|
|
13
|
+
returncode: int
|
|
14
|
+
stdout: str
|
|
15
|
+
stderr: str
|
|
16
|
+
duration: float
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def ok(self) -> bool:
|
|
20
|
+
return self.returncode == 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ShellExecutor:
|
|
24
|
+
"""Execute commands in a POSIX shell environment."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, shell: str = "/bin/bash") -> None:
|
|
27
|
+
self.shell = shell
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
self,
|
|
31
|
+
command: str,
|
|
32
|
+
cwd: Optional[str | Path] = None,
|
|
33
|
+
timeout: Optional[float] = None,
|
|
34
|
+
env: Optional[dict[str, str]] = None,
|
|
35
|
+
) -> CommandResult:
|
|
36
|
+
started = time.monotonic()
|
|
37
|
+
completed = subprocess.run(
|
|
38
|
+
command,
|
|
39
|
+
shell=True,
|
|
40
|
+
executable=self.shell,
|
|
41
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
42
|
+
env=env,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
duration = time.monotonic() - started
|
|
49
|
+
return CommandResult(
|
|
50
|
+
command=command,
|
|
51
|
+
returncode=completed.returncode,
|
|
52
|
+
stdout=completed.stdout,
|
|
53
|
+
stderr=completed.stderr,
|
|
54
|
+
duration=duration,
|
|
55
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GroqAPIError(RuntimeError):
|
|
10
|
+
"""Raised when the Groq API rejects a request."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GroqClient:
|
|
14
|
+
DEFAULT_MODELS = [
|
|
15
|
+
"llama-3.3-70b-versatile",
|
|
16
|
+
"llama-3.1-8b-instant",
|
|
17
|
+
"llama-3.3-8b",
|
|
18
|
+
"mixtral-8x7b-32768",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
NON_CHAT_TOKENS = (
|
|
22
|
+
"whisper",
|
|
23
|
+
"speech",
|
|
24
|
+
"tts",
|
|
25
|
+
"audio",
|
|
26
|
+
"orpheus",
|
|
27
|
+
"playai",
|
|
28
|
+
"embedding",
|
|
29
|
+
"transcription",
|
|
30
|
+
"rasr",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key: str, model: str = "auto", base_url: str = "https://api.groq.com/openai/v1") -> None:
|
|
34
|
+
if not api_key:
|
|
35
|
+
raise ValueError("Groq API key is required")
|
|
36
|
+
self.api_key = api_key
|
|
37
|
+
self.model = model
|
|
38
|
+
self.base_url = base_url.rstrip("/")
|
|
39
|
+
|
|
40
|
+
def _is_chat_model(self, model_id: str) -> bool:
|
|
41
|
+
lowered = model_id.lower()
|
|
42
|
+
return not any(token in lowered for token in self.NON_CHAT_TOKENS)
|
|
43
|
+
|
|
44
|
+
def resolve_model(self) -> str:
|
|
45
|
+
if self.model and self.model != "auto":
|
|
46
|
+
return self.model
|
|
47
|
+
|
|
48
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
49
|
+
|
|
50
|
+
with httpx.Client(timeout=30.0) as client:
|
|
51
|
+
response = client.get(f"{self.base_url}/models", headers=headers)
|
|
52
|
+
|
|
53
|
+
if response.status_code != 200:
|
|
54
|
+
return self.DEFAULT_MODELS[0]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
payload = response.json()
|
|
58
|
+
except ValueError:
|
|
59
|
+
return self.DEFAULT_MODELS[0]
|
|
60
|
+
|
|
61
|
+
ids = []
|
|
62
|
+
for item in payload.get("data", []):
|
|
63
|
+
model_id = item.get("id")
|
|
64
|
+
if model_id and self._is_chat_model(model_id):
|
|
65
|
+
ids.append(model_id)
|
|
66
|
+
|
|
67
|
+
for candidate in self.DEFAULT_MODELS:
|
|
68
|
+
if candidate in ids:
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
if ids:
|
|
72
|
+
return ids[0]
|
|
73
|
+
return self.DEFAULT_MODELS[0]
|
|
74
|
+
|
|
75
|
+
def chat_completion(self, messages: list[dict[str, str]], temperature: float = 0.2, max_tokens: int = 1024) -> str:
|
|
76
|
+
resolved_model = self.resolve_model()
|
|
77
|
+
self.model = resolved_model
|
|
78
|
+
|
|
79
|
+
payload = {
|
|
80
|
+
"model": resolved_model,
|
|
81
|
+
"messages": messages,
|
|
82
|
+
"temperature": temperature,
|
|
83
|
+
"max_tokens": max_tokens,
|
|
84
|
+
}
|
|
85
|
+
headers = {
|
|
86
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
with httpx.Client(timeout=60.0) as client:
|
|
91
|
+
response = client.post(f"{self.base_url}/chat/completions", headers=headers, json=payload)
|
|
92
|
+
|
|
93
|
+
if response.status_code != 200:
|
|
94
|
+
try:
|
|
95
|
+
detail = response.json()
|
|
96
|
+
except ValueError:
|
|
97
|
+
detail = response.text
|
|
98
|
+
raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
|
|
99
|
+
|
|
100
|
+
data = response.json()
|
|
101
|
+
choices = data.get("choices", [])
|
|
102
|
+
if not choices:
|
|
103
|
+
raise GroqAPIError("Groq API returned no choices")
|
|
104
|
+
message = choices[0].get("message", {})
|
|
105
|
+
content = message.get("content", "")
|
|
106
|
+
if not isinstance(content, str):
|
|
107
|
+
return json.dumps(content)
|
|
108
|
+
return content.strip()
|
|
109
|
+
|
|
110
|
+
def chat_completion_stream(
|
|
111
|
+
self,
|
|
112
|
+
messages: list[dict[str, str]],
|
|
113
|
+
temperature: float = 0.2,
|
|
114
|
+
max_tokens: int = 1024,
|
|
115
|
+
) -> Iterator[str]:
|
|
116
|
+
resolved_model = self.resolve_model()
|
|
117
|
+
self.model = resolved_model
|
|
118
|
+
|
|
119
|
+
payload = {
|
|
120
|
+
"model": resolved_model,
|
|
121
|
+
"messages": messages,
|
|
122
|
+
"temperature": temperature,
|
|
123
|
+
"max_tokens": max_tokens,
|
|
124
|
+
"stream": True,
|
|
125
|
+
}
|
|
126
|
+
headers = {
|
|
127
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
with httpx.Client(timeout=60.0) as client:
|
|
132
|
+
with client.stream("POST", f"{self.base_url}/chat/completions", headers=headers, json=payload) as response:
|
|
133
|
+
if response.status_code != 200:
|
|
134
|
+
try:
|
|
135
|
+
detail = response.json()
|
|
136
|
+
except ValueError:
|
|
137
|
+
detail = response.text
|
|
138
|
+
raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
|
|
139
|
+
|
|
140
|
+
for line in response.iter_lines():
|
|
141
|
+
if not line or not line.startswith("data:"):
|
|
142
|
+
continue
|
|
143
|
+
data = line[5:].strip()
|
|
144
|
+
if data == "[DONE]":
|
|
145
|
+
break
|
|
146
|
+
try:
|
|
147
|
+
event = json.loads(data)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
continue
|
|
150
|
+
choices = event.get("choices", [])
|
|
151
|
+
if not choices:
|
|
152
|
+
continue
|
|
153
|
+
delta = choices[0].get("delta", {})
|
|
154
|
+
content = delta.get("content")
|
|
155
|
+
if isinstance(content, str):
|
|
156
|
+
yield content
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConversationHistory:
|
|
10
|
+
"""Persist a conversation between a user and assistant in JSON format."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: str | Path = "~/.cobalt/history.json", title: str | None = None) -> None:
|
|
13
|
+
self.path = Path(path).expanduser()
|
|
14
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
self.title = title or self._default_title()
|
|
16
|
+
self._messages: list[dict[str, str]] = []
|
|
17
|
+
self.load()
|
|
18
|
+
|
|
19
|
+
def _default_title(self) -> str:
|
|
20
|
+
stem = self.path.stem.replace("chat-", "").replace("_", " ")
|
|
21
|
+
return stem.strip() or "new chat"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def messages(self) -> list[dict[str, str]]:
|
|
25
|
+
return self._messages
|
|
26
|
+
|
|
27
|
+
def load(self) -> list[dict[str, str]]:
|
|
28
|
+
if self.path.exists():
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
data = []
|
|
33
|
+
if isinstance(data, dict):
|
|
34
|
+
self.title = str(data.get("title") or self.title or self._default_title())
|
|
35
|
+
data = data.get("messages", [])
|
|
36
|
+
if isinstance(data, list):
|
|
37
|
+
self._messages = [
|
|
38
|
+
{"role": str(item.get("role", "user")), "content": str(item.get("content", ""))}
|
|
39
|
+
for item in data
|
|
40
|
+
if isinstance(item, dict)
|
|
41
|
+
]
|
|
42
|
+
else:
|
|
43
|
+
self._messages = []
|
|
44
|
+
else:
|
|
45
|
+
self._messages = []
|
|
46
|
+
return self._messages
|
|
47
|
+
|
|
48
|
+
def add(self, role: str, content: str) -> None:
|
|
49
|
+
self._messages.append({"role": role, "content": content})
|
|
50
|
+
self.save()
|
|
51
|
+
|
|
52
|
+
def save(self) -> None:
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
payload = {"title": self.title or self._default_title(), "messages": self._messages}
|
|
55
|
+
self.path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
def clear(self) -> None:
|
|
58
|
+
self._messages = []
|
|
59
|
+
self.save()
|
|
60
|
+
|
|
61
|
+
def latest(self, count: int = 10) -> list[dict[str, str]]:
|
|
62
|
+
return self._messages[-count:]
|
|
63
|
+
|
|
64
|
+
def __iter__(self):
|
|
65
|
+
return iter(self._messages)
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self._messages)
|
|
69
|
+
|
|
70
|
+
def __getitem__(self, index: int) -> dict[str, str]:
|
|
71
|
+
return self._messages[index]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ChatStore:
|
|
75
|
+
def __init__(self, root_dir: str | Path = "~/.cobalt/chats") -> None:
|
|
76
|
+
self.root_dir = Path(root_dir).expanduser()
|
|
77
|
+
self.root_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
def clear_all(self) -> None:
|
|
80
|
+
if self.root_dir.exists():
|
|
81
|
+
shutil.rmtree(self.root_dir)
|
|
82
|
+
self.root_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
def list_chats(self) -> list[Path]:
|
|
85
|
+
if not self.root_dir.exists():
|
|
86
|
+
return []
|
|
87
|
+
return sorted(self.root_dir.glob("*.json"))
|
|
88
|
+
|
|
89
|
+
def new_chat(self, title: str | None = None) -> ConversationHistory:
|
|
90
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
|
91
|
+
path = self.root_dir / f"chat-{timestamp}.json"
|
|
92
|
+
history = ConversationHistory(path, title=title or "new chat")
|
|
93
|
+
history.save()
|
|
94
|
+
return history
|
|
95
|
+
|
|
96
|
+
def load_chat(self, path: str | Path) -> ConversationHistory:
|
|
97
|
+
return ConversationHistory(path)
|
|
98
|
+
|
|
99
|
+
def delete_chat(self, path: str | Path) -> bool:
|
|
100
|
+
target = Path(path).expanduser()
|
|
101
|
+
if not target.exists():
|
|
102
|
+
return False
|
|
103
|
+
target.unlink()
|
|
104
|
+
return True
|
cobalt_cli_linux/tui.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import re
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .agent import CobaltAgent
|
|
9
|
+
from .history import ChatStore, ConversationHistory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CobaltTUI:
|
|
13
|
+
def __init__(self, settings) -> None:
|
|
14
|
+
self.settings = settings
|
|
15
|
+
self.chat_store = ChatStore("~/.cobalt/chats")
|
|
16
|
+
self.chat_files = self.chat_store.list_chats()
|
|
17
|
+
self.selected_chat_path = str(self.chat_files[-1]) if self.chat_files else None
|
|
18
|
+
if not self.chat_files:
|
|
19
|
+
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
20
|
+
self.selected_chat_path = str(self.current_chat.path)
|
|
21
|
+
else:
|
|
22
|
+
self.current_chat = self.chat_store.load_chat(self.selected_chat_path)
|
|
23
|
+
self.history = self.current_chat
|
|
24
|
+
self.saved_chats = self.chat_files
|
|
25
|
+
self.ascii_lines = self._load_ascii_art()
|
|
26
|
+
|
|
27
|
+
def _load_ascii_art(self) -> list[str]:
|
|
28
|
+
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
29
|
+
if not ascii_path.exists():
|
|
30
|
+
return ["COBALT"]
|
|
31
|
+
return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
32
|
+
|
|
33
|
+
def _refresh_chat_files(self) -> None:
|
|
34
|
+
self.chat_files = self.chat_store.list_chats()
|
|
35
|
+
if not self.chat_files:
|
|
36
|
+
self.selected_chat_path = None
|
|
37
|
+
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
38
|
+
self.history = self.current_chat
|
|
39
|
+
return
|
|
40
|
+
if self.selected_chat_path is None or not any(str(path) == self.selected_chat_path for path in self.chat_files):
|
|
41
|
+
self.selected_chat_path = str(self.chat_files[-1])
|
|
42
|
+
self.current_chat = self.chat_store.load_chat(self.selected_chat_path)
|
|
43
|
+
self.history = self.current_chat
|
|
44
|
+
|
|
45
|
+
def _get_chat_display_title(self, chat_path: Path) -> str:
|
|
46
|
+
history = self.chat_store.load_chat(chat_path)
|
|
47
|
+
if history.title and history.title not in {"new chat", "New chat", "untitled"}:
|
|
48
|
+
return history.title
|
|
49
|
+
return Path(chat_path).name.replace(".json", "")
|
|
50
|
+
|
|
51
|
+
def _suggest_chat_title(self, prompt: str) -> str:
|
|
52
|
+
cleaned = re.sub(r"[^A-Za-z0-9\s-]", "", prompt).strip()
|
|
53
|
+
words = cleaned.split()
|
|
54
|
+
if not words:
|
|
55
|
+
return "New chat"
|
|
56
|
+
title = " ".join(words[:4]).strip()
|
|
57
|
+
return title.title() if title else "New chat"
|
|
58
|
+
|
|
59
|
+
def _should_handle_shortcut(self, ch: int, input_text: str) -> bool:
|
|
60
|
+
if input_text.strip():
|
|
61
|
+
return False
|
|
62
|
+
return ch in (ord("n"), ord("d"))
|
|
63
|
+
|
|
64
|
+
def _compose_lines(self, text: str, width: int) -> list[str]:
|
|
65
|
+
lines: list[str] = []
|
|
66
|
+
for paragraph in text.splitlines() or [""]:
|
|
67
|
+
if not paragraph:
|
|
68
|
+
lines.append("")
|
|
69
|
+
continue
|
|
70
|
+
for wrapped in textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False):
|
|
71
|
+
lines.append(wrapped)
|
|
72
|
+
return lines
|
|
73
|
+
|
|
74
|
+
def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
|
|
75
|
+
if height < 3 or width < 4:
|
|
76
|
+
return
|
|
77
|
+
stdscr.addstr(top, left, "+" + "-" * (width - 2) + "+")
|
|
78
|
+
for y in range(top + 1, top + height - 1):
|
|
79
|
+
stdscr.addstr(y, left, "|")
|
|
80
|
+
stdscr.addstr(y, left + width - 1, "|")
|
|
81
|
+
stdscr.addstr(top + height - 1, left, "+" + "-" * (width - 2) + "+")
|
|
82
|
+
if title:
|
|
83
|
+
title_text = f" {title} "
|
|
84
|
+
title_start = left + 2
|
|
85
|
+
stdscr.addstr(top, title_start, title_text[: max(0, width - 4)], curses.A_BOLD)
|
|
86
|
+
|
|
87
|
+
def _render(self, stdscr, input_text: str, status: str) -> None:
|
|
88
|
+
stdscr.erase()
|
|
89
|
+
stdscr.bkgd(" ", curses.color_pair(0))
|
|
90
|
+
height, width = stdscr.getmaxyx()
|
|
91
|
+
|
|
92
|
+
sidebar_w = min(26, max(20, width // 4))
|
|
93
|
+
sidebar_x = 1
|
|
94
|
+
sidebar_y = 1
|
|
95
|
+
sidebar_h = max(8, height - 4)
|
|
96
|
+
self._draw_box(stdscr, sidebar_y, sidebar_x, sidebar_h, sidebar_w, " Chats ")
|
|
97
|
+
|
|
98
|
+
chat_files = self.chat_store.list_chats()[-8:]
|
|
99
|
+
for idx, chat_path in enumerate(chat_files):
|
|
100
|
+
y = sidebar_y + 2 + idx
|
|
101
|
+
if y >= sidebar_y + sidebar_h - 2:
|
|
102
|
+
break
|
|
103
|
+
chat_name = self._get_chat_display_title(Path(chat_path))
|
|
104
|
+
display = chat_name[: max(0, sidebar_w - 5)]
|
|
105
|
+
selected = str(chat_path) == str(self.selected_chat_path) if self.selected_chat_path else False
|
|
106
|
+
if selected:
|
|
107
|
+
stdscr.addstr(y, sidebar_x + 2, display, curses.A_REVERSE)
|
|
108
|
+
else:
|
|
109
|
+
stdscr.addstr(y, sidebar_x + 2, display)
|
|
110
|
+
|
|
111
|
+
main_x = sidebar_x + sidebar_w + 2
|
|
112
|
+
main_w = max(20, width - main_x - 2)
|
|
113
|
+
main_y = 1
|
|
114
|
+
main_h = max(8, height - 4)
|
|
115
|
+
self._draw_box(stdscr, main_y, main_x, main_h, main_w, " conversation ")
|
|
116
|
+
|
|
117
|
+
if not self.history.messages:
|
|
118
|
+
for idx, line in enumerate(self.ascii_lines):
|
|
119
|
+
y = main_y + 2 + idx
|
|
120
|
+
x = main_x + max(0, (main_w - len(line)) // 2)
|
|
121
|
+
stdscr.addstr(y, x, line[: max(0, main_w - 2)])
|
|
122
|
+
prompt = f"> {input_text}" if input_text else "> "
|
|
123
|
+
prompt_y = main_y + 2 + len(self.ascii_lines) + 2
|
|
124
|
+
prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
|
|
125
|
+
stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
|
|
126
|
+
else:
|
|
127
|
+
rows: list[str] = []
|
|
128
|
+
for message in self.history.latest(18):
|
|
129
|
+
role = message.get("role", "user").upper()
|
|
130
|
+
content = message.get("content", "")
|
|
131
|
+
prefix = f"[{role}] "
|
|
132
|
+
for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
|
|
133
|
+
rows.append(f"{prefix}{line}" if prefix and line else line)
|
|
134
|
+
prefix = ""
|
|
135
|
+
|
|
136
|
+
visible = rows[-max(4, main_h - 4):]
|
|
137
|
+
for idx, line in enumerate(visible[: main_h - 4]):
|
|
138
|
+
y = main_y + 1 + idx
|
|
139
|
+
clipped = line[: max(0, main_w - 4)]
|
|
140
|
+
stdscr.addstr(y, main_x + 2, clipped)
|
|
141
|
+
|
|
142
|
+
prompt = f"> {input_text}" if input_text else "> "
|
|
143
|
+
prompt_y = main_y + main_h - 3
|
|
144
|
+
prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
|
|
145
|
+
stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
|
|
146
|
+
|
|
147
|
+
status_line = status[: max(0, width - 1)]
|
|
148
|
+
stdscr.addstr(height - 1, 0, status_line)
|
|
149
|
+
stdscr.clrtoeol()
|
|
150
|
+
stdscr.refresh()
|
|
151
|
+
|
|
152
|
+
def _stream_response(self, stdscr, input_text: str) -> str:
|
|
153
|
+
buffer = ""
|
|
154
|
+
|
|
155
|
+
def on_chunk(chunk: str) -> None:
|
|
156
|
+
nonlocal buffer
|
|
157
|
+
buffer += chunk
|
|
158
|
+
if self.history.messages and self.history.messages[-1]["role"] == "assistant":
|
|
159
|
+
self.history.messages[-1]["content"] = buffer
|
|
160
|
+
self.history.save()
|
|
161
|
+
self._render(stdscr, input_text, "Streaming...")
|
|
162
|
+
|
|
163
|
+
agent = CobaltAgent(settings=self.settings)
|
|
164
|
+
agent.history = self.history
|
|
165
|
+
agent.process_stream(input_text.strip(), on_chunk=on_chunk)
|
|
166
|
+
self.history = agent.history
|
|
167
|
+
self.current_chat = self.history
|
|
168
|
+
self.saved_chats = self.chat_store.list_chats()
|
|
169
|
+
return buffer
|
|
170
|
+
|
|
171
|
+
def _handle_mouse_click(self, x: int, y: int) -> bool:
|
|
172
|
+
sidebar_w = min(26, max(20, curses.COLS // 4))
|
|
173
|
+
sidebar_x = 1
|
|
174
|
+
sidebar_y = 1
|
|
175
|
+
sidebar_h = max(8, curses.LINES - 4)
|
|
176
|
+
|
|
177
|
+
if sidebar_x <= x < sidebar_x + sidebar_w and sidebar_y <= y < sidebar_y + sidebar_h:
|
|
178
|
+
chat_files = self.chat_store.list_chats()[-8:]
|
|
179
|
+
if not chat_files:
|
|
180
|
+
return False
|
|
181
|
+
clicked_index = y - (sidebar_y + 2)
|
|
182
|
+
if 0 <= clicked_index < len(chat_files):
|
|
183
|
+
target = chat_files[clicked_index]
|
|
184
|
+
self.selected_chat_path = str(target)
|
|
185
|
+
self.history = self.chat_store.load_chat(target)
|
|
186
|
+
self.current_chat = self.history
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def run(self) -> int:
|
|
191
|
+
def _main(stdscr):
|
|
192
|
+
curses.curs_set(0)
|
|
193
|
+
curses.start_color()
|
|
194
|
+
curses.use_default_colors()
|
|
195
|
+
stdscr.bkgd(" ", curses.color_pair(0))
|
|
196
|
+
stdscr.nodelay(False)
|
|
197
|
+
stdscr.keypad(True)
|
|
198
|
+
curses.cbreak()
|
|
199
|
+
curses.noecho()
|
|
200
|
+
curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON1_CLICKED)
|
|
201
|
+
input_text = ""
|
|
202
|
+
status = "Ctrl+C or q to quit • Enter to send • n new chat • d delete chat"
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
self._render(stdscr, input_text, status)
|
|
206
|
+
ch = stdscr.getch()
|
|
207
|
+
|
|
208
|
+
if ch == curses.KEY_MOUSE:
|
|
209
|
+
_, x, y, _, button_state = curses.getmouse()
|
|
210
|
+
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED):
|
|
211
|
+
if self._handle_mouse_click(x, y):
|
|
212
|
+
status = "Chat selected"
|
|
213
|
+
input_text = ""
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if ch in (ord("q"), 3):
|
|
217
|
+
break
|
|
218
|
+
if ch in (ord("n"), ord("d")) and self._should_handle_shortcut(ch, input_text):
|
|
219
|
+
if ch == ord("n"):
|
|
220
|
+
new_chat = self.chat_store.new_chat(title="New chat")
|
|
221
|
+
self.current_chat = new_chat
|
|
222
|
+
self.history = self.current_chat
|
|
223
|
+
self.selected_chat_path = str(self.current_chat.path)
|
|
224
|
+
self.chat_files = self.chat_store.list_chats()
|
|
225
|
+
input_text = ""
|
|
226
|
+
status = "New chat started"
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if ch == ord("d") and self.selected_chat_path:
|
|
230
|
+
target = Path(self.selected_chat_path)
|
|
231
|
+
if target.exists():
|
|
232
|
+
self.chat_store.delete_chat(target)
|
|
233
|
+
self.chat_files = self.chat_store.list_chats()
|
|
234
|
+
if self.chat_files:
|
|
235
|
+
self.selected_chat_path = str(self.chat_files[-1])
|
|
236
|
+
self.history = self.chat_store.load_chat(self.selected_chat_path)
|
|
237
|
+
self.current_chat = self.history
|
|
238
|
+
else:
|
|
239
|
+
self.current_chat = self.chat_store.new_chat(title="New chat")
|
|
240
|
+
self.history = self.current_chat
|
|
241
|
+
self.selected_chat_path = str(self.current_chat.path)
|
|
242
|
+
input_text = ""
|
|
243
|
+
status = "Chat deleted"
|
|
244
|
+
continue
|
|
245
|
+
if ch in (10, 13, curses.KEY_ENTER):
|
|
246
|
+
if input_text.strip():
|
|
247
|
+
try:
|
|
248
|
+
if self.history.title in {"new chat", "New chat", None, ""}:
|
|
249
|
+
self.history.title = self._suggest_chat_title(input_text.strip())
|
|
250
|
+
self.history.save()
|
|
251
|
+
self._stream_response(stdscr, input_text)
|
|
252
|
+
status = "Response received"
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
status = f"Error: {exc}"
|
|
255
|
+
input_text = ""
|
|
256
|
+
continue
|
|
257
|
+
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
258
|
+
input_text = input_text[:-1]
|
|
259
|
+
continue
|
|
260
|
+
if ch in (curses.KEY_RESIZE,):
|
|
261
|
+
continue
|
|
262
|
+
if 32 <= ch <= 126:
|
|
263
|
+
input_text += chr(ch)
|
|
264
|
+
elif ch == 27:
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
return curses.wrapper(_main)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cobalt-cli-linux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
|
+
Author: Cobalt
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/owall4229/cobalt-cli-linux
|
|
8
|
+
Project-URL: Repository, https://github.com/owall4229/cobalt-cli-linux
|
|
9
|
+
Project-URL: Issues, https://github.com/owall4229/cobalt-cli-linux/issues
|
|
10
|
+
Keywords: cli,groq,agent,linux,assistant
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
23
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
24
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# cobalt-cli-linux
|
|
27
|
+
|
|
28
|
+
Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It can execute shell commands, read and write local files, maintain a local conversation history, and interact with Groq in a production-friendly Python package layout.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Groq chat completions via HTTP client
|
|
33
|
+
- Safe local shell execution with bash
|
|
34
|
+
- Persistent JSON conversation history in the user's home directory
|
|
35
|
+
- File read/write operations for scriptable agent workflows
|
|
36
|
+
- PyPI-ready package metadata and console script entrypoint
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m pip install cobalt-cli-linux
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Built-in key and .env override
|
|
45
|
+
|
|
46
|
+
The CLI ships with a built-in Groq API key and uses it automatically, so end users do not have to add their own key when they install the package.
|
|
47
|
+
|
|
48
|
+
If you want to override it for development or a local custom setup, create a `.env` file in the project root or in your home directory with:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
GROQ_API_KEY="your_groq_key_here"
|
|
52
|
+
GROQ_MODEL="llama-3.3-70b-versatile"
|
|
53
|
+
GROQ_BASE_URL="https://api.groq.com/openai/v1"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The CLI automatically reads `.env` entries when present, but it will keep using the built-in key unless you explicitly set `GROQ_API_KEY`. The default built-in key shipped with the package is:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
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
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Run command
|
|
63
|
+
|
|
64
|
+
### Standard command mode
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cobalt "List the top 10 files in the current directory"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Full-screen interactive mode
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cobalt --interactive
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
|
|
77
|
+
|
|
78
|
+
You can also pass arguments directly:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cobalt --api-key "$GROQ_API_KEY" "Show the current user and OS info"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Project layout
|
|
85
|
+
|
|
86
|
+
- src/cobalt_cli_linux/agent.py - orchestration logic for tool use and model responses
|
|
87
|
+
- src/cobalt_cli_linux/cli.py - command-line entrypoint
|
|
88
|
+
- src/cobalt_cli_linux/groq_client.py - Groq HTTP client
|
|
89
|
+
- src/cobalt_cli_linux/executor.py - shell command execution
|
|
90
|
+
- src/cobalt_cli_linux/history.py - local JSON conversation history
|
|
91
|
+
- src/cobalt_cli_linux/config.py - environment-based settings
|
|
92
|
+
|
|
93
|
+
## Publish to PyPI
|
|
94
|
+
|
|
95
|
+
From the project root, you can build and upload the package from the terminal:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m pip install --upgrade build twine
|
|
99
|
+
python -m build
|
|
100
|
+
python -m twine upload dist/*
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or with the included shortcut:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
make publish
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This creates the source and wheel distributions and uploads them to your configured PyPI account.
|
|
110
|
+
|
|
111
|
+
## Notes
|
|
112
|
+
|
|
113
|
+
This package is intentionally built to work on Debian Linux and uses standard POSIX shell commands. The default history file is stored at ~/.cobalt/history.json.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cobalt_cli_linux/__init__.py,sha256=czjL-_enpn4OvExtaAxpm0gkJIoYxwE0B_HZ67LQ-3s,76
|
|
2
|
+
cobalt_cli_linux/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
3
|
+
cobalt_cli_linux/agent.py,sha256=2-SJen7yJUB0SuoZBb6HfKmN9yK9QwIImUfsUBRt0f4,6089
|
|
4
|
+
cobalt_cli_linux/cli.py,sha256=bhy7He5L-zeqSqoUMFmLSYSJkQoYMClt4cYN_bopPNQ,2494
|
|
5
|
+
cobalt_cli_linux/config.py,sha256=IPD8mre4AnGdJ_f9DUalEOUI-7yTny0OoCTIV9gmMJU,1845
|
|
6
|
+
cobalt_cli_linux/deepseek_client.py,sha256=wcurNMePZA6IOP02veZ-nISzVNp-bt7hEgQ7LLDRHYs,1746
|
|
7
|
+
cobalt_cli_linux/executor.py,sha256=rrEuwQ2Qe5qDwFgEK_A0JK9wq8DsCxNd8-z-YwSSBcs,1334
|
|
8
|
+
cobalt_cli_linux/groq_client.py,sha256=bLNA2kjqahBtrWhEeBtT01GK4b81m8Hng8Sia62b3x8,5038
|
|
9
|
+
cobalt_cli_linux/history.py,sha256=ev-CJatLy_cMn6lFbkj6Kh-sGQQqRy5qtwRgolmlu_M,3575
|
|
10
|
+
cobalt_cli_linux/tui.py,sha256=gkB5t96fbMy-bbOT-2rx1s7jSHClrz5MOFvflUblZjM,11920
|
|
11
|
+
cobalt_cli_linux-0.1.0.dist-info/METADATA,sha256=aZzFp3n02U-Cp3pE-jFx_eQwGSmYNmjWuJ7wXXa5qsk,3732
|
|
12
|
+
cobalt_cli_linux-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
cobalt_cli_linux-0.1.0.dist-info/entry_points.txt,sha256=QAKmkri7o0hULbLBGnnSxSFH6HFnXWDWLSIkWPvwQgs,53
|
|
14
|
+
cobalt_cli_linux-0.1.0.dist-info/top_level.txt,sha256=0ih2JfV9PdRvYErd3d3EREsMKA9pt5czpgtUTt0bOP0,17
|
|
15
|
+
cobalt_cli_linux-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cobalt_cli_linux
|