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.
@@ -0,0 +1,5 @@
1
+ """Cobalt CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cobalt = cobalt_cli_linux.cli:main
@@ -0,0 +1 @@
1
+ cobalt_cli_linux