cobalt-cli-linux 0.1.4__tar.gz → 0.1.6__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.
Files changed (24) hide show
  1. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/PKG-INFO +3 -1
  2. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/README.md +2 -0
  3. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/pyproject.toml +1 -1
  4. cobalt_cli_linux-0.1.6/src/cobalt_cli_linux/ASCII.txt +5 -0
  5. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/agent.py +3 -0
  6. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/profiles.py +25 -2
  7. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/tui.py +122 -2
  8. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux.egg-info/PKG-INFO +3 -1
  9. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/tests/test_cli.py +36 -0
  10. cobalt_cli_linux-0.1.4/src/cobalt_cli_linux/ASCII.txt +0 -5
  11. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/setup.cfg +0 -0
  12. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/__init__.py +0 -0
  13. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/__main__.py +0 -0
  14. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/cli.py +0 -0
  15. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/config.py +0 -0
  16. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  17. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/executor.py +0 -0
  18. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/groq_client.py +0 -0
  19. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux/history.py +0 -0
  20. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux.egg-info/SOURCES.txt +0 -0
  21. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  22. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  23. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  24. {cobalt_cli_linux-0.1.4 → cobalt_cli_linux-0.1.6}/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.4
3
+ Version: 0.1.6
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -81,6 +81,8 @@ In the interface, click the `New chat` and `Delete chat` buttons to manage conve
81
81
 
82
82
  Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
83
83
 
84
+ The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
85
+
84
86
  You can also pass arguments directly:
85
87
 
86
88
  ```bash
@@ -56,6 +56,8 @@ In the interface, click the `New chat` and `Delete chat` buttons to manage conve
56
56
 
57
57
  Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
58
58
 
59
+ The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
60
+
59
61
  You can also pass arguments directly:
60
62
 
61
63
  ```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.4"
7
+ version = "0.1.6"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,5 @@
1
+ ____ _ _ _ ____ _
2
+ / ___|___ | |__ __ _| | |_ / ___|___ __| | ___
3
+ | | / _ \| '_ \ / _` | | __| | | / _ \ / _` |/ _ \
4
+ | |__| (_) | |_) | (_| | | |_ | |__| (_) | (_| | __/
5
+ \____\___/|_.__/ \__,_|_|\__| \____\___/ \__,_|\___|
@@ -169,6 +169,9 @@ class CobaltAgent:
169
169
  on_chunk(chunk)
170
170
  buffer = follow_up_buffer
171
171
 
172
+ if not buffer.strip():
173
+ buffer = "I could not produce a visible response for that request. Please try again."
174
+
172
175
  if self.history.messages and self.history.messages[-1].get("role") == "assistant":
173
176
  self.history.messages[-1]["content"] = buffer
174
177
  if thinking_buffer:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  from datetime import datetime, timedelta, timezone
5
6
  from pathlib import Path
@@ -13,12 +14,13 @@ class ProfileStore:
13
14
 
14
15
  def _load(self) -> dict:
15
16
  if not self.path.exists():
16
- return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
17
+ return {"profiles": {"guest": {"prompts": []}}, "accounts": {}, "active": "guest"}
17
18
  try:
18
19
  value = json.loads(self.path.read_text(encoding="utf-8"))
19
20
  except json.JSONDecodeError:
20
- return {"profiles": {"guest": {"prompts": []}}, "active": "guest"}
21
+ return {"profiles": {"guest": {"prompts": []}}, "accounts": {}, "active": "guest"}
21
22
  value.setdefault("profiles", {}).setdefault("guest", {"prompts": []})
23
+ value.setdefault("accounts", {})
22
24
  value.setdefault("active", "guest")
23
25
  return value
24
26
 
@@ -38,6 +40,27 @@ class ProfileStore:
38
40
  self.save()
39
41
  return name
40
42
 
43
+ def sign_up(self, name: str, password: str) -> str:
44
+ name = " ".join(name.strip().split())
45
+ if not name or not password:
46
+ raise ValueError("Name and password are required")
47
+ if name in self.data["accounts"]:
48
+ raise ValueError("Profile already exists")
49
+ digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
50
+ self.data["accounts"][name] = digest
51
+ self.data["profiles"].setdefault(name, {"prompts": []})
52
+ self.data["active"] = name
53
+ self.save()
54
+ return name
55
+
56
+ def sign_in(self, name: str, password: str) -> str:
57
+ digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
58
+ if self.data["accounts"].get(name) != digest:
59
+ raise ValueError("Invalid profile name or password")
60
+ self.data["active"] = name
61
+ self.save()
62
+ return name
63
+
41
64
  def delete(self, name: str | None = None) -> bool:
42
65
  name = name or self.active
43
66
  if name == "guest" or name not in self.data["profiles"]:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import curses
4
4
  import re
5
+ import shutil
6
+ import subprocess
5
7
  import textwrap
6
8
  from pathlib import Path
7
9
 
@@ -27,6 +29,9 @@ class CobaltTUI:
27
29
  self.ascii_lines = self._load_ascii_art()
28
30
  self.thinking_expanded: set[int] = set()
29
31
  self.profile_store = ProfileStore()
32
+ self.auth_view: str | None = None
33
+ self.auth_field = 0
34
+ self.auth_values = ["", ""]
30
35
 
31
36
  def _load_ascii_art(self) -> list[str]:
32
37
  ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
@@ -65,9 +70,15 @@ class CobaltTUI:
65
70
  new_label = "[ New chat ]"
66
71
  delete_x = max(1, width - len(delete_label) - 2)
67
72
  new_x = max(1, delete_x - len(new_label) - 2)
73
+ signup_label = "[ Sign up ]"
74
+ signin_label = "[ Sign in ]"
75
+ signin_x = max(1, new_x - len(signin_label) - 2)
76
+ signup_x = max(1, signin_x - len(signup_label) - 2)
68
77
  return {
69
78
  "new": (new_x, 0, len(new_label), 1),
70
79
  "delete": (delete_x, 0, len(delete_label), 1),
80
+ "signin": (signin_x, 0, len(signin_label), 1),
81
+ "signup": (signup_x, 0, len(signup_label), 1),
71
82
  }
72
83
 
73
84
  def _handle_button_click(self, x: int, y: int, width: int | None = None) -> str | None:
@@ -98,9 +109,41 @@ class CobaltTUI:
98
109
  else:
99
110
  self._start_new_chat()
100
111
 
112
+ def _open_auth(self, view: str) -> None:
113
+ self.auth_view = view
114
+ self.auth_field = 0
115
+ self.auth_values = ["", ""]
116
+ self.input_focused = True
117
+
118
+ def _submit_auth(self) -> None:
119
+ try:
120
+ if self.auth_view == "signup":
121
+ self.profile_store.sign_up(*self.auth_values)
122
+ elif self.auth_view == "signin":
123
+ self.profile_store.sign_in(*self.auth_values)
124
+ self.auth_view = None
125
+ self.auth_values = ["", ""]
126
+ except (ValueError, OSError) as exc:
127
+ self.auth_values[1] = str(exc)
128
+
129
+ def _render_auth(self, stdscr, height: int, width: int) -> None:
130
+ title = "Create profile" if self.auth_view == "signup" else "Sign in"
131
+ fields = ("Profile name", "Password")
132
+ panel_width = min(64, max(36, width - 8))
133
+ panel_x = max(2, (width - panel_width) // 2)
134
+ panel_y = max(3, (height - 12) // 2)
135
+ self._draw_box(stdscr, panel_y, panel_x, 10, panel_width, f" {title} ")
136
+ for index, label in enumerate(fields):
137
+ value = self.auth_values[index]
138
+ if index == 1 and value and not value.startswith("Invalid"):
139
+ value = "*" * len(value)
140
+ marker = "> " if index == self.auth_field else " "
141
+ stdscr.addstr(panel_y + 2 + index * 2, panel_x + 3, f"{marker}{label}: {value}"[: panel_width - 6])
142
+ stdscr.addstr(panel_y + 7, panel_x + 3, "Enter: next/submit | Escape: back"[: panel_width - 6], curses.A_DIM)
143
+
101
144
  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]]:
102
145
  box_width = max(20, main_w - 4)
103
- text_width = max(10, box_width - 6)
146
+ text_width = max(10, box_width - 6 - len("[ Copy prompt ]") - 2)
104
147
  input_lines = textwrap.wrap(
105
148
  input_text,
106
149
  width=text_width,
@@ -126,8 +169,33 @@ class CobaltTUI:
126
169
  for index, line in enumerate(input_lines):
127
170
  prompt = "> " if index == 0 else " "
128
171
  stdscr.addstr(box_y + 1 + index, box_x, f"{prompt}{line}"[:box_width])
172
+ copy_label = "[ Copy prompt ]"
173
+ copy_x = max(box_x + 4, box_x + box_width - len(copy_label) - 2)
174
+ stdscr.addstr(box_y + 1, copy_x, copy_label[: max(0, box_width - (copy_x - box_x))], curses.A_DIM)
129
175
  stdscr.addstr(box_y + box_height - 1, box_x, bottom[:box_width], curses.A_BOLD if focused else curses.A_DIM)
130
176
 
177
+ def _copy_to_clipboard(self, text: str) -> str:
178
+ if not text:
179
+ raise ValueError("Nothing to copy")
180
+ for command in ("wl-copy", "xclip", "xsel"):
181
+ executable = shutil.which(command)
182
+ if not executable:
183
+ continue
184
+ args = [executable]
185
+ if command == "xclip":
186
+ args.extend(["-selection", "clipboard"])
187
+ elif command == "xsel":
188
+ args.extend(["--clipboard", "--input"])
189
+ subprocess.run(args, input=text, text=True, check=True)
190
+ return "Copied"
191
+ raise RuntimeError("No clipboard utility found (install wl-clipboard, xclip, or xsel)")
192
+
193
+ def _latest_response(self) -> str:
194
+ for message in reversed(self.history.messages):
195
+ if message.get("role") == "assistant":
196
+ return message.get("content", "")
197
+ return ""
198
+
131
199
  def _compose_lines(self, text: str, width: int) -> list[str]:
132
200
  lines: list[str] = []
133
201
  for paragraph in text.splitlines() or [""]:
@@ -188,9 +256,15 @@ class CobaltTUI:
188
256
  header = " Cobalt Code | terminal agent "
189
257
  stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
190
258
  for name, (button_x, button_y, button_width, _) in self._button_regions(width).items():
191
- label = "[ New chat ]" if name == "new" else "[ Delete chat ]"
259
+ labels = {"new": "[ New chat ]", "delete": "[ Delete chat ]", "signin": "[ Sign in ]", "signup": "[ Sign up ]"}
260
+ label = labels[name]
192
261
  stdscr.addstr(button_y, button_x, label[:button_width], curses.A_REVERSE if name == "new" else curses.A_NORMAL)
193
262
 
263
+ if self.auth_view:
264
+ self._render_auth(stdscr, height, width)
265
+ stdscr.refresh()
266
+ return
267
+
194
268
  sidebar_w = min(26, max(20, width // 4))
195
269
  sidebar_x = 1
196
270
  sidebar_y = 6
@@ -239,6 +313,10 @@ class CobaltTUI:
239
313
  clipped = line[: max(0, main_w - 4)]
240
314
  stdscr.addstr(y, main_x + 2, clipped, attr)
241
315
 
316
+ copy_label = "[ Copy response ]"
317
+ copy_y = min(box_y - 1, main_y + main_h - box_height - 2)
318
+ stdscr.addstr(copy_y, main_x + 2, copy_label, curses.A_DIM)
319
+
242
320
  self._draw_input_box(stdscr, box_x, box_y, box_width, box_height, input_lines, self.input_focused)
243
321
 
244
322
  if status:
@@ -306,6 +384,21 @@ class CobaltTUI:
306
384
  box_x, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
307
385
  return box_x <= x < box_x + box_width and box_y <= y < box_y + box_height
308
386
 
387
+ def _copy_button_at(self, x: int, y: int, input_text: str) -> str | None:
388
+ height, width = curses.LINES, curses.COLS
389
+ sidebar_w = min(26, max(20, width // 4))
390
+ main_x, main_y = 3 + sidebar_w, 6
391
+ main_w, main_h = max(20, width - main_x - 2), max(8, height - 9)
392
+ _, box_y, box_width, box_height, _ = self._input_box_geometry(main_x, main_y, main_w, main_h, input_text)
393
+ prompt_copy_x = main_x + 2 + box_width - len("[ Copy prompt ]") - 2
394
+ if prompt_copy_x <= x < prompt_copy_x + len("[ Copy prompt ]") and box_y + 1 <= y <= box_y + 1:
395
+ return "prompt"
396
+ if self.history.messages:
397
+ copy_y = min(box_y - 1, main_y + main_h - box_height - 2)
398
+ if main_x + 2 <= x < main_x + 2 + len("[ Copy response ]") and y == copy_y:
399
+ return "response"
400
+ return None
401
+
309
402
  def _handle_thinking_click(self, x: int, y: int) -> bool:
310
403
  height, width = curses.LINES, curses.COLS
311
404
  sidebar_w = min(26, max(20, width // 4))
@@ -348,7 +441,17 @@ class CobaltTUI:
348
441
 
349
442
  if ch == curses.KEY_MOUSE:
350
443
  _, x, y, _, button_state = curses.getmouse()
444
+ copy_target = self._copy_button_at(x, y, input_text) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
445
+ if copy_target:
446
+ try:
447
+ self._copy_to_clipboard(self._latest_response() if copy_target == "response" else input_text)
448
+ except (OSError, RuntimeError, ValueError) as exc:
449
+ self.history.add("assistant", f"Error: {exc}")
450
+ continue
351
451
  button = self._handle_button_click(x, y, stdscr.getmaxyx()[1]) if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) else None
452
+ if button in ("signin", "signup"):
453
+ self._open_auth(button)
454
+ continue
352
455
  if button == "new":
353
456
  self._start_new_chat()
354
457
  input_text = ""
@@ -373,8 +476,25 @@ class CobaltTUI:
373
476
  if ch == 3:
374
477
  break
375
478
  if ch == 27:
479
+ if self.auth_view:
480
+ self.auth_view = None
481
+ self.auth_values = ["", ""]
482
+ continue
376
483
  self.input_focused = False
377
484
  continue
485
+ if self.auth_view:
486
+ if ch in (10, 13, curses.KEY_ENTER):
487
+ if self.auth_field == 0:
488
+ self.auth_field = 1
489
+ else:
490
+ self._submit_auth()
491
+ continue
492
+ if ch in (curses.KEY_BACKSPACE, 127, 8):
493
+ self.auth_values[self.auth_field] = self.auth_values[self.auth_field][:-1]
494
+ continue
495
+ if 32 <= ch <= 126:
496
+ self.auth_values[self.auth_field] += chr(ch)
497
+ continue
378
498
  if ch in (10, 13, curses.KEY_ENTER):
379
499
  if input_text.strip():
380
500
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -81,6 +81,8 @@ In the interface, click the `New chat` and `Delete chat` buttons to manage conve
81
81
 
82
82
  Assistant reasoning is stored separately and can be expanded by clicking its `thinking` row. Markdown headings, lists, inline backticks, and fenced code are styled in the terminal. Guest profiles are limited to three prompts per hour.
83
83
 
84
+ The interface includes `Sign in` and `Sign up` buttons for local profiles. Profiles are stored locally; guest mode remains available with the prompt limit.
85
+
84
86
  You can also pass arguments directly:
85
87
 
86
88
  ```bash
@@ -1,5 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
+ import pytest
4
+
3
5
  from cobalt_cli_linux import cli
4
6
  from cobalt_cli_linux.agent import CobaltAgent
5
7
  from cobalt_cli_linux.config import Settings
@@ -58,6 +60,31 @@ def test_tui_buttons_replace_chat_shortcuts() -> None:
58
60
  assert tui._handle_button_click(buttons["delete"][0], buttons["delete"][1], 120) == "delete"
59
61
 
60
62
 
63
+ def test_tui_copy_uses_available_clipboard_command(monkeypatch) -> None:
64
+ tui = CobaltTUI(settings=Settings())
65
+ tui.history.add("assistant", "response text")
66
+ captured = {}
67
+
68
+ monkeypatch.setattr("cobalt_cli_linux.tui.shutil.which", lambda command: "/usr/bin/wl-copy" if command == "wl-copy" else None)
69
+ monkeypatch.setattr("cobalt_cli_linux.tui.subprocess.run", lambda args, **kwargs: captured.update(args=args, kwargs=kwargs))
70
+
71
+ assert tui._latest_response() == "response text"
72
+ assert tui._copy_to_clipboard("prompt text") == "Copied"
73
+ assert captured["kwargs"]["input"] == "prompt text"
74
+
75
+
76
+ def test_streaming_empty_answer_gets_visible_fallback(monkeypatch, tmp_path: Path) -> None:
77
+ agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
78
+ agent.profile_store.data["active"] = "personal"
79
+
80
+ monkeypatch.setattr(agent.client, "chat_completion_stream_events", lambda *args, **kwargs: iter(()))
81
+
82
+ response = agent.process_stream("hello")
83
+
84
+ assert response.startswith("I could not produce a visible response")
85
+ assert agent.history.messages[-1]["content"] == response
86
+
87
+
61
88
  def test_no_prompt_launches_tui(monkeypatch) -> None:
62
89
  launched = []
63
90
 
@@ -133,6 +160,15 @@ def test_profiles_create_delete_and_guest_limit(tmp_path: Path) -> None:
133
160
  assert store.active == "guest"
134
161
 
135
162
 
163
+ def test_profiles_sign_up_and_sign_in(tmp_path: Path) -> None:
164
+ store = ProfileStore(tmp_path / "profiles.json")
165
+
166
+ assert store.sign_up("Ada", "secret") == "Ada"
167
+ assert store.sign_in("Ada", "secret") == "Ada"
168
+ with pytest.raises(ValueError):
169
+ store.sign_in("Ada", "wrong")
170
+
171
+
136
172
  def test_tui_empty_chat_does_not_load_prompt_art_as_body_content(tmp_path: Path) -> None:
137
173
  tui = CobaltTUI(settings=Settings())
138
174
 
@@ -1,5 +0,0 @@
1
- ____ ___ ____ _ _ _____ ____ _ ___
2
- / ___/ _ \| __ ) / \ | | |_ _| / ___| | |_ _|
3
- | | | | | | _ \ / _ \ | | | | | | | | | |
4
- | |__| |_| | |_) / ___ \| |___| | | |___| |___ | |
5
- \____\___/|____/_/ \_\_____|_| \____|_____|___|