cobalt-cli-linux 0.1.0__tar.gz → 0.1.2__tar.gz
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-0.1.0 → cobalt_cli_linux-0.1.2}/PKG-INFO +5 -1
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/README.md +4 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/pyproject.toml +4 -1
- cobalt_cli_linux-0.1.2/src/cobalt_cli_linux/ASCII.txt +5 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/agent.py +50 -20
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/cli.py +2 -6
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/groq_client.py +31 -28
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/tui.py +88 -38
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/PKG-INFO +5 -1
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/tests/test_cli.py +95 -4
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/setup.cfg +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/__init__.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/__main__.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/config.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/deepseek_client.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/executor.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux/history.py +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
- {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cobalt-cli-linux
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
|
|
|
35
35
|
- File read/write operations for scriptable agent workflows
|
|
36
36
|
- PyPI-ready package metadata and console script entrypoint
|
|
37
37
|
|
|
38
|
+
When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
|
|
39
|
+
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
40
42
|
```bash
|
|
@@ -75,6 +77,8 @@ cobalt --interactive
|
|
|
75
77
|
|
|
76
78
|
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
79
|
|
|
80
|
+
In the interface, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
81
|
+
|
|
78
82
|
You can also pass arguments directly:
|
|
79
83
|
|
|
80
84
|
```bash
|
|
@@ -10,6 +10,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
|
|
|
10
10
|
- File read/write operations for scriptable agent workflows
|
|
11
11
|
- PyPI-ready package metadata and console script entrypoint
|
|
12
12
|
|
|
13
|
+
When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
|
|
14
|
+
|
|
13
15
|
## Installation
|
|
14
16
|
|
|
15
17
|
```bash
|
|
@@ -50,6 +52,8 @@ cobalt --interactive
|
|
|
50
52
|
|
|
51
53
|
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.
|
|
52
54
|
|
|
55
|
+
In the interface, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
56
|
+
|
|
53
57
|
You can also pass arguments directly:
|
|
54
58
|
|
|
55
59
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cobalt-cli-linux"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Agentic Groq-powered CLI assistant for Debian Linux"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,5 +45,8 @@ package-dir = {"" = "src"}
|
|
|
45
45
|
[tool.setuptools.packages.find]
|
|
46
46
|
where = ["src"]
|
|
47
47
|
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
cobalt_cli_linux = ["ASCII.txt"]
|
|
50
|
+
|
|
48
51
|
[tool.pytest.ini_options]
|
|
49
52
|
pythonpath = ["src"]
|
|
@@ -56,24 +56,49 @@ class CobaltAgent:
|
|
|
56
56
|
path = Path(path_text.strip())
|
|
57
57
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
58
|
path.write_text(content, encoding="utf-8")
|
|
59
|
+
if not path.is_file() or path.read_text(encoding="utf-8") != content:
|
|
60
|
+
raise OSError(f"File verification failed for {path}")
|
|
59
61
|
return f"Wrote {path}"
|
|
60
62
|
raise ValueError(f"Unsupported tool: {tool_name}")
|
|
61
63
|
|
|
62
64
|
def _extract_tools(self, response: str) -> list[tuple[str, str]]:
|
|
63
65
|
tools: list[tuple[str, str]] = []
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
lines = response.splitlines()
|
|
67
|
+
index = 0
|
|
68
|
+
while index < len(lines):
|
|
69
|
+
match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", lines[index].strip())
|
|
70
|
+
if not match:
|
|
71
|
+
index += 1
|
|
68
72
|
continue
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
|
|
74
|
+
tool_name, payload = match.groups()
|
|
75
|
+
if tool_name == "WRITE_FILE":
|
|
76
|
+
content_lines: list[str] = []
|
|
77
|
+
index += 1
|
|
78
|
+
while index < len(lines) and not re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*", lines[index].strip()):
|
|
79
|
+
content_lines.append(lines[index])
|
|
80
|
+
index += 1
|
|
81
|
+
tools.append((tool_name, f"{payload}\n{chr(10).join(content_lines)}"))
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
tools.append((tool_name, payload))
|
|
85
|
+
index += 1
|
|
75
86
|
return tools
|
|
76
87
|
|
|
88
|
+
def _tool_results(self, response: str) -> list[str]:
|
|
89
|
+
results: list[str] = []
|
|
90
|
+
for tool_name, payload in self._extract_tools(response):
|
|
91
|
+
try:
|
|
92
|
+
result = self._execute_tool(tool_name, payload)
|
|
93
|
+
results.append(f"{tool_name} result:\n{result}")
|
|
94
|
+
except (OSError, ValueError, RuntimeError) as exc: # pragma: no cover - defensive path
|
|
95
|
+
results.append(f"{tool_name} error:\n{exc}")
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
def _follow_up_messages(self, tool_results: list[str]) -> list[dict[str, str]]:
|
|
99
|
+
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."
|
|
100
|
+
return [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}]
|
|
101
|
+
|
|
77
102
|
def process(self, prompt: str) -> str:
|
|
78
103
|
if not self.settings.api_key_configured:
|
|
79
104
|
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.")
|
|
@@ -87,17 +112,9 @@ class CobaltAgent:
|
|
|
87
112
|
self.history.add("assistant", response)
|
|
88
113
|
return response
|
|
89
114
|
|
|
90
|
-
tool_results
|
|
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."
|
|
115
|
+
tool_results = self._tool_results(response)
|
|
99
116
|
follow_up = self.client.chat_completion(
|
|
100
|
-
|
|
117
|
+
self._follow_up_messages(tool_results),
|
|
101
118
|
temperature=self.settings.temperature,
|
|
102
119
|
max_tokens=self.settings.max_tokens,
|
|
103
120
|
)
|
|
@@ -128,6 +145,19 @@ class CobaltAgent:
|
|
|
128
145
|
if on_chunk is not None:
|
|
129
146
|
on_chunk(chunk)
|
|
130
147
|
|
|
148
|
+
tool_results = self._tool_results(buffer)
|
|
149
|
+
if tool_results:
|
|
150
|
+
follow_up_buffer = ""
|
|
151
|
+
for chunk in self.client.chat_completion_stream(
|
|
152
|
+
self._follow_up_messages(tool_results),
|
|
153
|
+
temperature=self.settings.temperature,
|
|
154
|
+
max_tokens=self.settings.max_tokens,
|
|
155
|
+
):
|
|
156
|
+
follow_up_buffer += chunk
|
|
157
|
+
if on_chunk is not None:
|
|
158
|
+
on_chunk(chunk)
|
|
159
|
+
buffer = follow_up_buffer
|
|
160
|
+
|
|
131
161
|
if self.history.messages and self.history.messages[-1].get("role") == "assistant":
|
|
132
162
|
self.history.messages[-1]["content"] = buffer
|
|
133
163
|
self.history.save()
|
|
@@ -35,20 +35,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
35
35
|
max_tokens=args.max_tokens if args.max_tokens is not None else 1024,
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
if args.interactive:
|
|
38
|
+
if args.interactive or not args.prompt:
|
|
39
39
|
return CobaltTUI(settings).run()
|
|
40
40
|
|
|
41
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
42
|
try:
|
|
47
43
|
agent = CobaltAgent(settings=settings)
|
|
48
44
|
response = agent.process(prompt)
|
|
49
45
|
print(response)
|
|
50
46
|
return 0
|
|
51
|
-
except
|
|
47
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
52
48
|
print(f"Error: {exc}", file=sys.stderr)
|
|
53
49
|
return 1
|
|
54
50
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import ClassVar
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ class GroqAPIError(RuntimeError):
|
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class GroqClient:
|
|
14
|
-
DEFAULT_MODELS = [
|
|
15
|
+
DEFAULT_MODELS: ClassVar[list[str]] = [
|
|
15
16
|
"llama-3.3-70b-versatile",
|
|
16
17
|
"llama-3.1-8b-instant",
|
|
17
18
|
"llama-3.3-8b",
|
|
@@ -128,29 +129,31 @@ class GroqClient:
|
|
|
128
129
|
"Content-Type": "application/json",
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
with httpx.Client(timeout=60.0) as client
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
132
|
+
with httpx.Client(timeout=60.0) as client, client.stream(
|
|
133
|
+
"POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
|
|
134
|
+
) as response:
|
|
135
|
+
if response.status_code != 200:
|
|
136
|
+
response.read()
|
|
137
|
+
try:
|
|
138
|
+
detail = response.json()
|
|
139
|
+
except ValueError:
|
|
140
|
+
detail = response.text
|
|
141
|
+
raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
|
|
142
|
+
|
|
143
|
+
for line in response.iter_lines():
|
|
144
|
+
if not line or not line.startswith("data:"):
|
|
145
|
+
continue
|
|
146
|
+
data = line[5:].strip()
|
|
147
|
+
if data == "[DONE]":
|
|
148
|
+
break
|
|
149
|
+
try:
|
|
150
|
+
event = json.loads(data)
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
continue
|
|
153
|
+
choices = event.get("choices", [])
|
|
154
|
+
if not choices:
|
|
155
|
+
continue
|
|
156
|
+
delta = choices[0].get("delta", {})
|
|
157
|
+
content = delta.get("content")
|
|
158
|
+
if isinstance(content, str):
|
|
159
|
+
yield content
|
|
@@ -6,7 +6,7 @@ import textwrap
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from .agent import CobaltAgent
|
|
9
|
-
from .history import ChatStore
|
|
9
|
+
from .history import ChatStore
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class CobaltTUI:
|
|
@@ -23,9 +23,12 @@ class CobaltTUI:
|
|
|
23
23
|
self.history = self.current_chat
|
|
24
24
|
self.saved_chats = self.chat_files
|
|
25
25
|
self.ascii_lines = self._load_ascii_art()
|
|
26
|
+
self.input_focused = True
|
|
26
27
|
|
|
27
28
|
def _load_ascii_art(self) -> list[str]:
|
|
28
|
-
ascii_path = Path(__file__).
|
|
29
|
+
ascii_path = Path(__file__).with_name("ASCII.txt")
|
|
30
|
+
if not ascii_path.exists():
|
|
31
|
+
ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
|
|
29
32
|
if not ascii_path.exists():
|
|
30
33
|
return ["COBALT"]
|
|
31
34
|
return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
@@ -56,10 +59,38 @@ class CobaltTUI:
|
|
|
56
59
|
title = " ".join(words[:4]).strip()
|
|
57
60
|
return title.title() if title else "New chat"
|
|
58
61
|
|
|
59
|
-
def _should_handle_shortcut(self, ch: int,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
def _should_handle_shortcut(self, ch: int, input_focused: bool = False) -> bool:
|
|
63
|
+
return not input_focused and ch in (14, 4, ord("n"), ord("d"))
|
|
64
|
+
|
|
65
|
+
def _input_box_geometry(self, main_x: int, main_y: int, main_w: int, main_h: int, input_text: str) -> tuple[int, int, int, int, list[str]]:
|
|
66
|
+
box_width = max(20, main_w - 4)
|
|
67
|
+
text_width = max(10, box_width - 6)
|
|
68
|
+
input_lines = textwrap.wrap(
|
|
69
|
+
input_text,
|
|
70
|
+
width=text_width,
|
|
71
|
+
replace_whitespace=False,
|
|
72
|
+
drop_whitespace=False,
|
|
73
|
+
break_long_words=True,
|
|
74
|
+
break_on_hyphens=False,
|
|
75
|
+
) or [""]
|
|
76
|
+
max_lines = max(1, min(6, main_h - 6))
|
|
77
|
+
input_lines = input_lines[-max_lines:]
|
|
78
|
+
box_height = len(input_lines) + 3
|
|
79
|
+
box_x = main_x + 2
|
|
80
|
+
box_y = main_y + main_h - box_height - 1
|
|
81
|
+
return box_x, box_y, box_width, box_height, input_lines
|
|
82
|
+
|
|
83
|
+
def _draw_input_box(self, stdscr, box_x: int, box_y: int, box_width: int, box_height: int, input_lines: list[str], focused: bool) -> None:
|
|
84
|
+
rule = "-" * max(0, box_width - 2)
|
|
85
|
+
top_label = " Ask AI "
|
|
86
|
+
top = f"-{top_label}{rule}"
|
|
87
|
+
bottom_label = " [Enter to submit] "
|
|
88
|
+
bottom = f"-{'-' * max(0, box_width - len(bottom_label) - 2)}{bottom_label}-"
|
|
89
|
+
stdscr.addstr(box_y, box_x, top[:box_width])
|
|
90
|
+
for index, line in enumerate(input_lines):
|
|
91
|
+
prompt = "> " if index == 0 else " "
|
|
92
|
+
stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
|
|
93
|
+
stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
|
|
63
94
|
|
|
64
95
|
def _compose_lines(self, text: str, width: int) -> list[str]:
|
|
65
96
|
lines: list[str] = []
|
|
@@ -67,8 +98,7 @@ class CobaltTUI:
|
|
|
67
98
|
if not paragraph:
|
|
68
99
|
lines.append("")
|
|
69
100
|
continue
|
|
70
|
-
|
|
71
|
-
lines.append(wrapped)
|
|
101
|
+
lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
|
|
72
102
|
return lines
|
|
73
103
|
|
|
74
104
|
def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
|
|
@@ -89,6 +119,9 @@ class CobaltTUI:
|
|
|
89
119
|
stdscr.bkgd(" ", curses.color_pair(0))
|
|
90
120
|
height, width = stdscr.getmaxyx()
|
|
91
121
|
|
|
122
|
+
header = " Cobalt Code | terminal agent "
|
|
123
|
+
stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
|
|
124
|
+
|
|
92
125
|
sidebar_w = min(26, max(20, width // 4))
|
|
93
126
|
sidebar_x = 1
|
|
94
127
|
sidebar_y = 1
|
|
@@ -112,41 +145,41 @@ class CobaltTUI:
|
|
|
112
145
|
main_w = max(20, width - main_x - 2)
|
|
113
146
|
main_y = 1
|
|
114
147
|
main_h = max(8, height - 4)
|
|
115
|
-
self._draw_box(stdscr, main_y, main_x, main_h, main_w, " conversation ")
|
|
148
|
+
self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
|
|
116
149
|
|
|
150
|
+
box_x, box_y, box_width, box_height, input_lines = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
117
151
|
if not self.history.messages:
|
|
152
|
+
content_height = max(1, box_y - main_y - 1)
|
|
153
|
+
art_start = main_y + max(1, (content_height - len(self.ascii_lines)) // 2)
|
|
118
154
|
for idx, line in enumerate(self.ascii_lines):
|
|
119
|
-
y =
|
|
155
|
+
y = art_start + idx
|
|
120
156
|
x = main_x + max(0, (main_w - len(line)) // 2)
|
|
121
|
-
|
|
122
|
-
|
|
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)
|
|
157
|
+
if y < box_y:
|
|
158
|
+
stdscr.addstr(y, x, line[: max(0, main_w - 2)])
|
|
126
159
|
else:
|
|
127
160
|
rows: list[str] = []
|
|
128
161
|
for message in self.history.latest(18):
|
|
129
162
|
role = message.get("role", "user").upper()
|
|
130
163
|
content = message.get("content", "")
|
|
131
|
-
prefix = f"
|
|
164
|
+
prefix = f"{role.lower()} "
|
|
132
165
|
for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
|
|
133
166
|
rows.append(f"{prefix}{line}" if prefix and line else line)
|
|
134
167
|
prefix = ""
|
|
135
168
|
|
|
136
|
-
visible = rows[-max(
|
|
169
|
+
visible = rows[-max(1, box_y - main_y - 2):]
|
|
137
170
|
for idx, line in enumerate(visible[: main_h - 4]):
|
|
138
171
|
y = main_y + 1 + idx
|
|
172
|
+
if y >= box_y:
|
|
173
|
+
break
|
|
139
174
|
clipped = line[: max(0, main_w - 4)]
|
|
140
175
|
stdscr.addstr(y, main_x + 2, clipped)
|
|
141
176
|
|
|
142
|
-
|
|
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)
|
|
177
|
+
self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
|
|
146
178
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
179
|
+
if status:
|
|
180
|
+
status_line = status[: max(0, width - 1)]
|
|
181
|
+
stdscr.addstr(height - 1, 0, status_line)
|
|
182
|
+
stdscr.clrtoeol()
|
|
150
183
|
stdscr.refresh()
|
|
151
184
|
|
|
152
185
|
def _stream_response(self, stdscr, input_text: str) -> str:
|
|
@@ -158,7 +191,7 @@ class CobaltTUI:
|
|
|
158
191
|
if self.history.messages and self.history.messages[-1]["role"] == "assistant":
|
|
159
192
|
self.history.messages[-1]["content"] = buffer
|
|
160
193
|
self.history.save()
|
|
161
|
-
self._render(stdscr, input_text, "
|
|
194
|
+
self._render(stdscr, input_text, "")
|
|
162
195
|
|
|
163
196
|
agent = CobaltAgent(settings=self.settings)
|
|
164
197
|
agent.history = self.history
|
|
@@ -187,6 +220,16 @@ class CobaltTUI:
|
|
|
187
220
|
return True
|
|
188
221
|
return False
|
|
189
222
|
|
|
223
|
+
def _click_is_in_input(self, x: int, y: int, input_text: str) -> bool:
|
|
224
|
+
height, width = curses.LINES, curses.COLS
|
|
225
|
+
sidebar_w = min(26, max(20, width // 4))
|
|
226
|
+
main_x = 3 + sidebar_w
|
|
227
|
+
main_y = 1
|
|
228
|
+
main_w = max(20, width - main_x - 2)
|
|
229
|
+
main_h = max(8, height - 4)
|
|
230
|
+
box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
|
|
231
|
+
return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
|
|
232
|
+
|
|
190
233
|
def run(self) -> int:
|
|
191
234
|
def _main(stdscr):
|
|
192
235
|
curses.curs_set(0)
|
|
@@ -199,7 +242,8 @@ class CobaltTUI:
|
|
|
199
242
|
curses.noecho()
|
|
200
243
|
curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON1_CLICKED)
|
|
201
244
|
input_text = ""
|
|
202
|
-
|
|
245
|
+
self.input_focused = True
|
|
246
|
+
status = ""
|
|
203
247
|
|
|
204
248
|
while True:
|
|
205
249
|
self._render(stdscr, input_text, status)
|
|
@@ -207,16 +251,22 @@ class CobaltTUI:
|
|
|
207
251
|
|
|
208
252
|
if ch == curses.KEY_MOUSE:
|
|
209
253
|
_, x, y, _, button_state = curses.getmouse()
|
|
210
|
-
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED):
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
254
|
+
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._click_is_in_input(x, y, input_text):
|
|
255
|
+
self.input_focused = True
|
|
256
|
+
continue
|
|
257
|
+
if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
|
|
258
|
+
status = "Chat selected"
|
|
259
|
+
input_text = ""
|
|
260
|
+
self.input_focused = False
|
|
214
261
|
continue
|
|
215
262
|
|
|
216
|
-
if ch
|
|
263
|
+
if ch == 3:
|
|
217
264
|
break
|
|
218
|
-
if ch
|
|
219
|
-
|
|
265
|
+
if ch == 27:
|
|
266
|
+
self.input_focused = False
|
|
267
|
+
continue
|
|
268
|
+
if self._should_handle_shortcut(ch, self.input_focused):
|
|
269
|
+
if ch in (14, ord("n")):
|
|
220
270
|
new_chat = self.chat_store.new_chat(title="New chat")
|
|
221
271
|
self.current_chat = new_chat
|
|
222
272
|
self.history = self.current_chat
|
|
@@ -226,7 +276,7 @@ class CobaltTUI:
|
|
|
226
276
|
status = "New chat started"
|
|
227
277
|
continue
|
|
228
278
|
|
|
229
|
-
if ch
|
|
279
|
+
if ch in (4, ord("d")) and self.selected_chat_path:
|
|
230
280
|
target = Path(self.selected_chat_path)
|
|
231
281
|
if target.exists():
|
|
232
282
|
self.chat_store.delete_chat(target)
|
|
@@ -250,8 +300,9 @@ class CobaltTUI:
|
|
|
250
300
|
self.history.save()
|
|
251
301
|
self._stream_response(stdscr, input_text)
|
|
252
302
|
status = "Response received"
|
|
253
|
-
except
|
|
254
|
-
|
|
303
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
304
|
+
self.history.add("assistant", f"Error: {exc}")
|
|
305
|
+
status = ""
|
|
255
306
|
input_text = ""
|
|
256
307
|
continue
|
|
257
308
|
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
@@ -260,9 +311,8 @@ class CobaltTUI:
|
|
|
260
311
|
if ch in (curses.KEY_RESIZE,):
|
|
261
312
|
continue
|
|
262
313
|
if 32 <= ch <= 126:
|
|
314
|
+
self.input_focused = True
|
|
263
315
|
input_text += chr(ch)
|
|
264
|
-
elif ch == 27:
|
|
265
|
-
break
|
|
266
316
|
|
|
267
317
|
return 0
|
|
268
318
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cobalt-cli-linux
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Agentic Groq-powered CLI assistant for Debian Linux
|
|
5
5
|
Author: Cobalt
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
|
|
|
35
35
|
- File read/write operations for scriptable agent workflows
|
|
36
36
|
- PyPI-ready package metadata and console script entrypoint
|
|
37
37
|
|
|
38
|
+
When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
|
|
39
|
+
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
40
42
|
```bash
|
|
@@ -75,6 +77,8 @@ cobalt --interactive
|
|
|
75
77
|
|
|
76
78
|
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
79
|
|
|
80
|
+
In the interface, press `Escape` to unfocus the prompt. While unfocused, `n` starts a new chat and `d` deletes the selected chat; click the prompt or type a character to focus it again. Long prompts wrap inside the prompt box.
|
|
81
|
+
|
|
78
82
|
You can also pass arguments directly:
|
|
79
83
|
|
|
80
84
|
```bash
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
+
from cobalt_cli_linux import cli
|
|
4
|
+
from cobalt_cli_linux.agent import CobaltAgent
|
|
3
5
|
from cobalt_cli_linux.config import Settings
|
|
4
6
|
from cobalt_cli_linux.executor import ShellExecutor
|
|
5
7
|
from cobalt_cli_linux.groq_client import GroqClient
|
|
@@ -47,10 +49,99 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
|
|
|
47
49
|
def test_tui_shortcuts_ignore_typed_letters() -> None:
|
|
48
50
|
tui = CobaltTUI(settings=Settings())
|
|
49
51
|
|
|
50
|
-
assert tui._should_handle_shortcut(
|
|
51
|
-
assert tui._should_handle_shortcut(
|
|
52
|
-
assert tui._should_handle_shortcut(ord("n")
|
|
53
|
-
assert tui._should_handle_shortcut(ord("d")
|
|
52
|
+
assert tui._should_handle_shortcut(14) is True
|
|
53
|
+
assert tui._should_handle_shortcut(4) is True
|
|
54
|
+
assert tui._should_handle_shortcut(ord("n")) is True
|
|
55
|
+
assert tui._should_handle_shortcut(ord("d")) is True
|
|
56
|
+
assert tui._should_handle_shortcut(ord("q")) is False
|
|
57
|
+
assert tui._should_handle_shortcut(ord("n"), True) is False
|
|
58
|
+
assert tui._should_handle_shortcut(ord("d"), True) is False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_q_is_not_a_shortcut_when_typing() -> None:
|
|
62
|
+
tui = CobaltTUI(settings=Settings())
|
|
63
|
+
|
|
64
|
+
assert tui._should_handle_shortcut(ord("q")) is False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_no_prompt_launches_tui(monkeypatch) -> None:
|
|
68
|
+
launched = []
|
|
69
|
+
|
|
70
|
+
class FakeTUI:
|
|
71
|
+
def __init__(self, settings):
|
|
72
|
+
launched.append(settings)
|
|
73
|
+
|
|
74
|
+
def run(self):
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(cli, "CobaltTUI", FakeTUI)
|
|
78
|
+
|
|
79
|
+
assert cli.main([]) == 0
|
|
80
|
+
assert launched
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None:
|
|
84
|
+
class FakeResponse:
|
|
85
|
+
status_code = 401
|
|
86
|
+
text = "unauthorized"
|
|
87
|
+
was_read = False
|
|
88
|
+
|
|
89
|
+
def read(self):
|
|
90
|
+
self.was_read = True
|
|
91
|
+
|
|
92
|
+
def json(self):
|
|
93
|
+
assert self.was_read is True
|
|
94
|
+
return {"error": {"message": "unauthorized"}}
|
|
95
|
+
|
|
96
|
+
def __enter__(self):
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, exc_type, exc, tb):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
class FakeStreamClient:
|
|
103
|
+
def __init__(self, timeout=None):
|
|
104
|
+
self.response = FakeResponse()
|
|
105
|
+
|
|
106
|
+
def __enter__(self):
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, exc_type, exc, tb):
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def get(self, url, headers=None):
|
|
113
|
+
return type("Response", (), {"status_code": 200, "json": lambda self: {"data": []}})()
|
|
114
|
+
|
|
115
|
+
def stream(self, method, url, headers=None, json=None):
|
|
116
|
+
return self.response
|
|
117
|
+
|
|
118
|
+
monkeypatch.setattr("cobalt_cli_linux.groq_client.httpx.Client", FakeStreamClient)
|
|
119
|
+
client = GroqClient("test-key", model="llama-3.1-8b-instant")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
next(client.chat_completion_stream([{"role": "user", "content": "hello"}]))
|
|
123
|
+
except RuntimeError as exc:
|
|
124
|
+
assert "401" in str(exc)
|
|
125
|
+
else:
|
|
126
|
+
raise AssertionError("Expected streamed API error")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
|
|
130
|
+
agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
|
|
131
|
+
target = tmp_path / "test.txt"
|
|
132
|
+
|
|
133
|
+
result = agent._execute_tool("WRITE_FILE", f"{target}\nhello")
|
|
134
|
+
|
|
135
|
+
assert result == f"Wrote {target}"
|
|
136
|
+
assert target.read_text(encoding="utf-8") == "hello"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_write_file_directive_is_parsed_once() -> None:
|
|
140
|
+
agent = CobaltAgent(settings=Settings())
|
|
141
|
+
|
|
142
|
+
tools = agent._extract_tools("WRITE_FILE: test.txt\nhello\nworld")
|
|
143
|
+
|
|
144
|
+
assert tools == [("WRITE_FILE", "test.txt\nhello\nworld")]
|
|
54
145
|
|
|
55
146
|
|
|
56
147
|
def test_shell_executor_runs_command() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/requires.txt
RENAMED
|
File without changes
|
{cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.2}/src/cobalt_cli_linux.egg-info/top_level.txt
RENAMED
|
File without changes
|