deepwrap 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.
- deepwrap/__init__.py +3 -0
- deepwrap/__main__.py +437 -0
- deepwrap/api/__init__.py +7 -0
- deepwrap/api/base.py +31 -0
- deepwrap/api/chat_session.py +303 -0
- deepwrap/api/chats.py +101 -0
- deepwrap/api/pow.py +92 -0
- deepwrap/client.py +136 -0
- deepwrap/config.py +74 -0
- deepwrap/core/__init__.py +4 -0
- deepwrap/core/auth.py +123 -0
- deepwrap/core/session_manager.py +55 -0
- deepwrap/interfaces/__init__.py +1 -0
- deepwrap/interfaces/api.py +373 -0
- deepwrap/interfaces/cli.py +966 -0
- deepwrap/modules/__init__.py +3 -0
- deepwrap/modules/pow_asm.py +305 -0
- deepwrap/py.typed +0 -0
- deepwrap/utils/bearer_token_extractor.py +56 -0
- deepwrap/utils/browser_finder.py +172 -0
- deepwrap/utils/browser_process.py +72 -0
- deepwrap/utils/cdp_client.py +82 -0
- deepwrap/utils/config_store.py +101 -0
- deepwrap/utils/dev_tools_http.py +90 -0
- deepwrap/utils/port_finder.py +17 -0
- deepwrap-0.1.0.dist-info/METADATA +860 -0
- deepwrap-0.1.0.dist-info/RECORD +31 -0
- deepwrap-0.1.0.dist-info/WHEEL +5 -0
- deepwrap-0.1.0.dist-info/entry_points.txt +2 -0
- deepwrap-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepwrap-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from deepwrap.utils.config_store import AppConfig as CLIConfig
|
|
14
|
+
from deepwrap.utils.config_store import ConfigStore
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from rich.console import Console, Group
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.markdown import Markdown
|
|
20
|
+
from rich.rule import Rule
|
|
21
|
+
from rich.spinner import Spinner
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
except ImportError as exc: # pragma: no cover
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"Missing dependency: rich. Install it with: pip install rich"
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from prompt_toolkit import PromptSession
|
|
30
|
+
from prompt_toolkit.application import Application
|
|
31
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
32
|
+
from prompt_toolkit.completion import Completer, Completion, CompleteEvent
|
|
33
|
+
from prompt_toolkit.document import Document
|
|
34
|
+
from prompt_toolkit.history import FileHistory
|
|
35
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
36
|
+
from prompt_toolkit.layout import Layout
|
|
37
|
+
from prompt_toolkit.layout.containers import Window
|
|
38
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
39
|
+
from prompt_toolkit.styles import Style
|
|
40
|
+
except ImportError as exc: # pragma: no cover
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"Missing dependency: prompt_toolkit. Install it with: pip install prompt_toolkit"
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
from deepwrap import Client
|
|
46
|
+
from deepwrap.core import Auth
|
|
47
|
+
|
|
48
|
+
APP_NAME = "deepwrap"
|
|
49
|
+
TOKEN_ENV_NAME = "DEEPWRAP_API_KEY"
|
|
50
|
+
SUPPORTED_MODELS = ("expert", "default", "vision")
|
|
51
|
+
COMMANDS = (
|
|
52
|
+
"/help",
|
|
53
|
+
"/exit",
|
|
54
|
+
"/quit",
|
|
55
|
+
"/clear",
|
|
56
|
+
"/new",
|
|
57
|
+
"/model",
|
|
58
|
+
"/token",
|
|
59
|
+
"/thinking",
|
|
60
|
+
"/search",
|
|
61
|
+
"/god",
|
|
62
|
+
"/save",
|
|
63
|
+
"/status",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
HELP_ITEMS = [
|
|
67
|
+
("/help", "Show this help"),
|
|
68
|
+
("/exit", "Exit the CLI"),
|
|
69
|
+
("/clear", "Clear the terminal"),
|
|
70
|
+
("/new", "Start a fresh chat session"),
|
|
71
|
+
("/model <name>", "Switch model (expert, default, vision)"),
|
|
72
|
+
("/token", "Set token interactively"),
|
|
73
|
+
('/token "<token>"', "Set token inline"),
|
|
74
|
+
("/thinking on|off", "Show or hide <think> blocks in UI"),
|
|
75
|
+
("/search on|off", "Enable or disable search"),
|
|
76
|
+
("/god on|off", "Enable or disable God Mode"),
|
|
77
|
+
("/save", "Save current settings to config"),
|
|
78
|
+
("/status", "Show current session status"),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SlashCommandCompleter(Completer):
|
|
84
|
+
"""
|
|
85
|
+
Autocomplete slash commands only when the input starts with a slash.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent):
|
|
89
|
+
text = document.text_before_cursor
|
|
90
|
+
|
|
91
|
+
if not text.startswith("/"):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
for command in COMMANDS:
|
|
95
|
+
if command.startswith(text):
|
|
96
|
+
yield Completion(
|
|
97
|
+
command,
|
|
98
|
+
start_position = -len(text),
|
|
99
|
+
display = command,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DeepWrapCLI:
|
|
104
|
+
"""
|
|
105
|
+
Interactive terminal UI for DeepWrap.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self.console = Console(highlight = False)
|
|
110
|
+
self.store = ConfigStore()
|
|
111
|
+
self.config = self.store.load()
|
|
112
|
+
|
|
113
|
+
env_token = os.getenv(TOKEN_ENV_NAME) or os.getenv("DEEPSEEK_API_KEY")
|
|
114
|
+
if env_token:
|
|
115
|
+
self.config.token = env_token
|
|
116
|
+
|
|
117
|
+
history_path = self.store.path.parent / "history.txt"
|
|
118
|
+
|
|
119
|
+
self.prompt = PromptSession(
|
|
120
|
+
history = FileHistory(str(history_path)),
|
|
121
|
+
auto_suggest = AutoSuggestFromHistory(),
|
|
122
|
+
completer = SlashCommandCompleter(),
|
|
123
|
+
complete_while_typing = True,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.client: Optional[Client] = None
|
|
127
|
+
self.chat = None
|
|
128
|
+
self._last_ctrl_c_at = 0.0
|
|
129
|
+
|
|
130
|
+
if self.config.token:
|
|
131
|
+
self._boot_client()
|
|
132
|
+
|
|
133
|
+
def _erase_last_input_line(self) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Remove the last prompt line from the terminal.
|
|
136
|
+
|
|
137
|
+
This is used to avoid showing the raw prompt-toolkit input line and the
|
|
138
|
+
styled user bubble at the same time.
|
|
139
|
+
|
|
140
|
+
The sequence is intentionally conservative because moving the cursor
|
|
141
|
+
around near the terminal bottom edge is fragile on some terminals.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
stream = self.console.file
|
|
146
|
+
|
|
147
|
+
if hasattr(stream, "isatty") and not stream.isatty():
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
stream.write("\r")
|
|
151
|
+
stream.write("\x1b[2K")
|
|
152
|
+
stream.write("\x1b[1A")
|
|
153
|
+
stream.write("\x1b[2K")
|
|
154
|
+
stream.write("\r")
|
|
155
|
+
stream.flush()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def run(self) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Run the interactive CLI loop.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
self._clear_screen()
|
|
165
|
+
self._render_header()
|
|
166
|
+
self._render_intro()
|
|
167
|
+
|
|
168
|
+
if not self.config.token:
|
|
169
|
+
self._print_system(
|
|
170
|
+
"No token configured.",
|
|
171
|
+
style = "yellow",
|
|
172
|
+
)
|
|
173
|
+
self._offer_token_setup()
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
try:
|
|
177
|
+
user_input = self.prompt.prompt(
|
|
178
|
+
self._prompt_text(),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self._erase_last_input_line()
|
|
182
|
+
self._last_ctrl_c_at = 0.0
|
|
183
|
+
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
now = time.monotonic()
|
|
186
|
+
|
|
187
|
+
if now - self._last_ctrl_c_at <= 1.2:
|
|
188
|
+
self.console.print()
|
|
189
|
+
self._print_system("Bye.", style = "dim")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self._last_ctrl_c_at = now
|
|
193
|
+
self.console.print()
|
|
194
|
+
self._print_system(
|
|
195
|
+
"Input cancelled. Press Ctrl+C again to exit.",
|
|
196
|
+
style = "yellow",
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
except EOFError:
|
|
201
|
+
self.console.print()
|
|
202
|
+
self._print_system("Bye.", style = "dim")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
message = user_input.strip()
|
|
206
|
+
if not message:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if message.startswith("/"):
|
|
210
|
+
if not self._handle_command(message):
|
|
211
|
+
return
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if not self._ensure_chat():
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
self._render_user(message)
|
|
218
|
+
self._stream_assistant(message)
|
|
219
|
+
|
|
220
|
+
def _handle_command(self, raw: str) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Handle slash commands.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
False if the CLI should exit, otherwise True.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
parts = shlex.split(raw)
|
|
230
|
+
except ValueError as exc:
|
|
231
|
+
self._print_system(f"Command parse error: {exc}", style = "red")
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
command = parts[0].lower()
|
|
235
|
+
args = parts[1:]
|
|
236
|
+
|
|
237
|
+
if command in {"/exit", "/quit"}:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
if command == "/help":
|
|
241
|
+
self._render_help()
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
if command == "/clear":
|
|
245
|
+
self._clear_screen()
|
|
246
|
+
self._render_header()
|
|
247
|
+
self._render_intro()
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
if command == "/new":
|
|
251
|
+
self._create_chat()
|
|
252
|
+
self._print_system("Started a new chat session.", style = "green")
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
if command == "/status":
|
|
256
|
+
self._render_status()
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
if command == "/save":
|
|
260
|
+
self.store.save(self.config)
|
|
261
|
+
self._print_system(f"Saved config to {self.store.path}", style = "green")
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
if command == "/model":
|
|
265
|
+
if not args:
|
|
266
|
+
self._print_system(
|
|
267
|
+
f"Current model: {self.config.model}",
|
|
268
|
+
style = "cyan",
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
model = args[0].strip().lower()
|
|
273
|
+
|
|
274
|
+
if model not in SUPPORTED_MODELS:
|
|
275
|
+
self._print_system(
|
|
276
|
+
f"Unsupported model: {model}. Supported: {', '.join(SUPPORTED_MODELS)}",
|
|
277
|
+
style = "red",
|
|
278
|
+
)
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
self.config.model = model
|
|
282
|
+
self._create_chat()
|
|
283
|
+
self._print_system(f"Model switched to {model}.", style = "green")
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
if command == "/thinking":
|
|
287
|
+
if not args:
|
|
288
|
+
state = "on" if self.config.show_thinking else "off"
|
|
289
|
+
self._print_system(f"Thinking display is {state}.", style = "cyan")
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
value = args[0].strip().lower()
|
|
293
|
+
|
|
294
|
+
if value not in {"on", "off"}:
|
|
295
|
+
self._print_system("Usage: /thinking on|off", style = "red")
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
self.config.show_thinking = value == "on"
|
|
299
|
+
state = "enabled" if self.config.show_thinking else "hidden"
|
|
300
|
+
self._print_system(f"Thinking blocks are now {state}.", style = "green")
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
if command == "/search":
|
|
304
|
+
if not args:
|
|
305
|
+
state = "on" if self.config.search_enabled else "off"
|
|
306
|
+
self._print_system(f"Search is {state}.", style = "cyan")
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
value = args[0].strip().lower()
|
|
310
|
+
|
|
311
|
+
if value not in {"on", "off"}:
|
|
312
|
+
self._print_system("Usage: /search on|off", style = "red")
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
self.config.search_enabled = value == "on"
|
|
316
|
+
state = "enabled" if self.config.search_enabled else "disabled"
|
|
317
|
+
self._print_system(f"Search is now {state}.", style = "green")
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
if command == "/god":
|
|
321
|
+
if not args:
|
|
322
|
+
state = "on" if self.config.god_mode else "off"
|
|
323
|
+
self._print_system(f"God Mode is {state}.", style = "cyan")
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
value = args[0].strip().lower()
|
|
327
|
+
|
|
328
|
+
if value not in {"on", "off"}:
|
|
329
|
+
self._print_system("Usage: /god on|off", style = "red")
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
self.config.god_mode = value == "on"
|
|
333
|
+
self._create_chat()
|
|
334
|
+
state = "enabled" if self.config.god_mode else "disabled"
|
|
335
|
+
self._print_system(f"God Mode is now {state}.", style = "green")
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
if command == "/token":
|
|
339
|
+
inline_token = args[0] if args else None
|
|
340
|
+
self._set_token_interactive(inline_token)
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
self._print_system(f"Unknown command: {command}", style = "red")
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
def _offer_token_setup(self) -> None:
|
|
347
|
+
"""
|
|
348
|
+
Offer a modern auth method picker when no token is configured.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
choice = self._select_auth_method()
|
|
352
|
+
|
|
353
|
+
if choice == "browser":
|
|
354
|
+
self._run_browser_auth()
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
if choice == "manual":
|
|
358
|
+
self._set_token_interactive()
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
self._print_system(
|
|
362
|
+
"Token setup skipped. You can run /token later.",
|
|
363
|
+
style = "yellow",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _select_auth_method(self) -> Optional[str]:
|
|
367
|
+
"""
|
|
368
|
+
Show a modern arrow-key auth selector.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
"browser", "manual", or None.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
options = [
|
|
375
|
+
(
|
|
376
|
+
"browser",
|
|
377
|
+
"Login via browser (Auto)",
|
|
378
|
+
"Open the browser and let Client.auth.browser() handle auth.",
|
|
379
|
+
),
|
|
380
|
+
(
|
|
381
|
+
"manual",
|
|
382
|
+
"Use Bearer (Manual)",
|
|
383
|
+
"Paste a bearer token with a cleaner prompt.",
|
|
384
|
+
),
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
selected = {"index": 0}
|
|
388
|
+
|
|
389
|
+
def get_fragments():
|
|
390
|
+
fragments = [
|
|
391
|
+
("fg:#60a5fa bold", " DeepWrap setup\n"),
|
|
392
|
+
("fg:#94a3b8", " Choose an auth method\n\n"),
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
for index, (_, label, description) in enumerate(options):
|
|
396
|
+
active = index == selected["index"]
|
|
397
|
+
|
|
398
|
+
if active:
|
|
399
|
+
fragments.extend(
|
|
400
|
+
[
|
|
401
|
+
("bg:#172554 fg:#ffffff bold", " ● "),
|
|
402
|
+
("bg:#172554 fg:#bfdbfe bold", f"{label}"),
|
|
403
|
+
("", "\n"),
|
|
404
|
+
("fg:#93c5fd", f" {description}\n\n"),
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
fragments.extend(
|
|
409
|
+
[
|
|
410
|
+
("fg:#475569", " ○ "),
|
|
411
|
+
("fg:#94a3b8", f"{label}"),
|
|
412
|
+
("", "\n"),
|
|
413
|
+
("fg:#64748b", f" {description}\n\n"),
|
|
414
|
+
]
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
fragments.append(
|
|
418
|
+
("fg:#64748b", " ↑/↓ or Tab to move • Enter to confirm • Esc to cancel ")
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return fragments
|
|
422
|
+
|
|
423
|
+
kb = KeyBindings()
|
|
424
|
+
|
|
425
|
+
@kb.add("up")
|
|
426
|
+
def _(event):
|
|
427
|
+
selected["index"] = (selected["index"] - 1) % len(options)
|
|
428
|
+
|
|
429
|
+
@kb.add("down")
|
|
430
|
+
@kb.add("tab")
|
|
431
|
+
def _(event):
|
|
432
|
+
selected["index"] = (selected["index"] + 1) % len(options)
|
|
433
|
+
|
|
434
|
+
@kb.add("s-tab")
|
|
435
|
+
def _(event):
|
|
436
|
+
selected["index"] = (selected["index"] - 1) % len(options)
|
|
437
|
+
|
|
438
|
+
@kb.add("enter")
|
|
439
|
+
def _(event):
|
|
440
|
+
event.app.exit(result = options[selected["index"]][0])
|
|
441
|
+
|
|
442
|
+
@kb.add("escape")
|
|
443
|
+
@kb.add("c-c")
|
|
444
|
+
def _(event):
|
|
445
|
+
event.app.exit(result = None)
|
|
446
|
+
|
|
447
|
+
style = Style.from_dict(
|
|
448
|
+
{
|
|
449
|
+
"dialog": "bg:#020617",
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
body = Window(
|
|
454
|
+
content = FormattedTextControl(get_fragments),
|
|
455
|
+
always_hide_cursor = True,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
app = Application(
|
|
459
|
+
layout = Layout(body),
|
|
460
|
+
key_bindings = kb,
|
|
461
|
+
full_screen = False,
|
|
462
|
+
mouse_support = False,
|
|
463
|
+
style = style,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
self.console.print()
|
|
467
|
+
result = app.run()
|
|
468
|
+
self.console.print()
|
|
469
|
+
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
def _run_browser_auth(self) -> None:
|
|
473
|
+
"""
|
|
474
|
+
Start browser-based auth via the client's auth API.
|
|
475
|
+
|
|
476
|
+
This assumes your Client supports being instantiated without a token
|
|
477
|
+
and exposes `client.auth.browser()`.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
self._clear_screen()
|
|
481
|
+
self._render_header()
|
|
482
|
+
self._render_intro()
|
|
483
|
+
|
|
484
|
+
self._print_system(
|
|
485
|
+
"Starting browser auth...",
|
|
486
|
+
style = "cyan",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
result = Auth().browser()
|
|
491
|
+
|
|
492
|
+
if result is None:
|
|
493
|
+
self._print_system("Browser auth was cancelled or failed.", style = "red")
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
except KeyboardInterrupt:
|
|
497
|
+
self._print_system("Browser auth cancelled.", style = "yellow")
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
except Exception as exc:
|
|
501
|
+
self._print_system(f"Browser auth failed: {exc}", style = "red")
|
|
502
|
+
self._offer_token_setup()
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
token = self._extract_token_from_auth_result(result)
|
|
506
|
+
|
|
507
|
+
if not token or token is None or len(token.strip()) == 0:
|
|
508
|
+
self._print_system(
|
|
509
|
+
"Browser auth did not return a token.",
|
|
510
|
+
style = "red",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
self._offer_token_setup()
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
self.config.token = token
|
|
517
|
+
self._boot_client(reset_chat = True)
|
|
518
|
+
self._print_system("Browser auth completed.", style = "green")
|
|
519
|
+
|
|
520
|
+
save_local = self._ask_yes_no(
|
|
521
|
+
"Save token to DeepWrap config file?",
|
|
522
|
+
default = True,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if save_local:
|
|
526
|
+
self.store.save(self.config)
|
|
527
|
+
self._print_system(f"Saved token to {self.store.path}", style = "green")
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _extract_token_from_auth_result(result) -> Optional[str]:
|
|
531
|
+
"""
|
|
532
|
+
Normalize the return value of `client.auth.browser()` into a token.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
if isinstance(result, str):
|
|
536
|
+
return result.strip() or None
|
|
537
|
+
|
|
538
|
+
if isinstance(result, dict):
|
|
539
|
+
for key in ("token", "api_key", "bearer_token", "access_token"):
|
|
540
|
+
value = result.get(key)
|
|
541
|
+
if isinstance(value, str) and value.strip():
|
|
542
|
+
return value.strip()
|
|
543
|
+
|
|
544
|
+
for attr in ("token", "api_key", "bearer_token", "access_token"):
|
|
545
|
+
value = getattr(result, attr, None)
|
|
546
|
+
if isinstance(value, str) and value.strip():
|
|
547
|
+
return value.strip()
|
|
548
|
+
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def _set_token_interactive(self, token: Optional[str] = None) -> None:
|
|
552
|
+
"""
|
|
553
|
+
Set the API token and optionally persist it.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
if not token:
|
|
557
|
+
self.console.print()
|
|
558
|
+
self.console.print(Text("manual auth", style = "bold #60a5fa"))
|
|
559
|
+
self.console.print(
|
|
560
|
+
Text(
|
|
561
|
+
"Paste your bearer token below. It will not be echoed.",
|
|
562
|
+
style = "#93c5fd",
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
token = self.prompt.prompt(
|
|
566
|
+
[("#60a5fa bold", " token "), ("#93c5fd", "> ")],
|
|
567
|
+
is_password = True,
|
|
568
|
+
).strip()
|
|
569
|
+
self.console.print()
|
|
570
|
+
|
|
571
|
+
if not token:
|
|
572
|
+
self._print_system("Token was empty. Nothing changed.", style = "yellow")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
self.config.token = token
|
|
576
|
+
try:
|
|
577
|
+
self._boot_client(reset_chat = True)
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
self._print_system(f"Failed to boot client: {exc}", style = "red")
|
|
580
|
+
self._select_auth_method()
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
self._print_system("Token updated for current session.", style = "green")
|
|
584
|
+
|
|
585
|
+
save_local = self._ask_yes_no(
|
|
586
|
+
"Save token to DeepWrap config file?",
|
|
587
|
+
default = True,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if save_local:
|
|
591
|
+
self.store.save(self.config)
|
|
592
|
+
self._print_system(f"Saved token to {self.store.path}", style = "green")
|
|
593
|
+
|
|
594
|
+
save_system = self._ask_yes_no(
|
|
595
|
+
f"Persist token to system environment variable {TOKEN_ENV_NAME}?",
|
|
596
|
+
default = False,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
if save_system:
|
|
600
|
+
ok, message = self._persist_system_env(TOKEN_ENV_NAME, token)
|
|
601
|
+
self._print_system(message, style = "green" if ok else "yellow")
|
|
602
|
+
|
|
603
|
+
def _persist_system_env(self, key: str, value: str) -> tuple[bool, str]:
|
|
604
|
+
"""
|
|
605
|
+
Persist a token as a user-level environment variable.
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
if os.name == "nt":
|
|
610
|
+
subprocess.run(
|
|
611
|
+
["setx", key, value],
|
|
612
|
+
check = True,
|
|
613
|
+
capture_output = True,
|
|
614
|
+
text = True,
|
|
615
|
+
)
|
|
616
|
+
return True, f"Saved {key} with setx. Restart your terminal to load it."
|
|
617
|
+
|
|
618
|
+
profile = Path.home() / ".profile"
|
|
619
|
+
line = f'export {key}="{value}"\n'
|
|
620
|
+
|
|
621
|
+
if profile.exists():
|
|
622
|
+
existing = profile.read_text(encoding = "utf-8")
|
|
623
|
+
updated = re.sub(
|
|
624
|
+
rf'^export\s+{re.escape(key)}=.*$\n?',
|
|
625
|
+
'',
|
|
626
|
+
existing,
|
|
627
|
+
flags = re.MULTILINE,
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
updated = ""
|
|
631
|
+
|
|
632
|
+
updated += ("\n" if updated and not updated.endswith("\n") else "") + line
|
|
633
|
+
profile.write_text(updated, encoding = "utf-8")
|
|
634
|
+
|
|
635
|
+
return True, f"Saved {key} to {profile}. Restart your shell to load it."
|
|
636
|
+
|
|
637
|
+
except Exception as exc: # pragma: no cover
|
|
638
|
+
return False, f"Could not persist system env token: {exc}"
|
|
639
|
+
|
|
640
|
+
def _stream_assistant(self, prompt: str) -> None:
|
|
641
|
+
"""
|
|
642
|
+
Render assistant output live with markdown formatting.
|
|
643
|
+
|
|
644
|
+
The live region is intentionally cropped near the terminal bottom edge
|
|
645
|
+
to avoid crashes or cursor corruption in small terminals. After the
|
|
646
|
+
streaming finishes, the final full content is printed statically.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
thinking = ""
|
|
650
|
+
answer = ""
|
|
651
|
+
phase = "thinking"
|
|
652
|
+
|
|
653
|
+
with Live(
|
|
654
|
+
self._build_live_renderable(thinking, answer, phase),
|
|
655
|
+
console = self.console,
|
|
656
|
+
refresh_per_second = 18,
|
|
657
|
+
transient = True,
|
|
658
|
+
vertical_overflow = "ellipsis",
|
|
659
|
+
) as live:
|
|
660
|
+
try:
|
|
661
|
+
for kind, chunk in self.chat.respond_structured(
|
|
662
|
+
prompt = prompt,
|
|
663
|
+
thinking = True,
|
|
664
|
+
search = self.config.search_enabled,
|
|
665
|
+
):
|
|
666
|
+
if kind == "think":
|
|
667
|
+
thinking += chunk
|
|
668
|
+
|
|
669
|
+
if not answer:
|
|
670
|
+
phase = "thinking"
|
|
671
|
+
|
|
672
|
+
elif kind == "response":
|
|
673
|
+
answer += chunk
|
|
674
|
+
phase = "response"
|
|
675
|
+
|
|
676
|
+
live.update(
|
|
677
|
+
self._build_live_renderable(
|
|
678
|
+
thinking = thinking,
|
|
679
|
+
answer = answer,
|
|
680
|
+
phase = phase,
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
except KeyboardInterrupt:
|
|
685
|
+
live.stop()
|
|
686
|
+
self.console.print()
|
|
687
|
+
self._print_system(
|
|
688
|
+
"Generation stopped.",
|
|
689
|
+
style = "yellow",
|
|
690
|
+
)
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
except Exception as exc:
|
|
694
|
+
live.update(Text(str(exc), style = "bold red"))
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
self._print_assistant_final(thinking, answer)
|
|
698
|
+
|
|
699
|
+
def _build_live_renderable(
|
|
700
|
+
self,
|
|
701
|
+
thinking: str,
|
|
702
|
+
answer: str,
|
|
703
|
+
phase: str,
|
|
704
|
+
):
|
|
705
|
+
"""
|
|
706
|
+
Build the temporary live renderable shown while a response is streaming.
|
|
707
|
+
"""
|
|
708
|
+
|
|
709
|
+
parts = []
|
|
710
|
+
|
|
711
|
+
if self.config.show_thinking:
|
|
712
|
+
if phase == "thinking":
|
|
713
|
+
parts.append(
|
|
714
|
+
Spinner(
|
|
715
|
+
"dots",
|
|
716
|
+
text = " thinking",
|
|
717
|
+
style = "bold #60a5fa",
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
elif thinking:
|
|
722
|
+
parts.append(Text("thinking", style = "bold #60a5fa"))
|
|
723
|
+
|
|
724
|
+
if thinking:
|
|
725
|
+
parts.append(Text(thinking, style = "dim #94a3b8"))
|
|
726
|
+
|
|
727
|
+
if thinking or phase == "thinking":
|
|
728
|
+
parts.append(Text(""))
|
|
729
|
+
|
|
730
|
+
else:
|
|
731
|
+
if phase == "thinking" and not answer:
|
|
732
|
+
parts.append(
|
|
733
|
+
Spinner(
|
|
734
|
+
"dots",
|
|
735
|
+
text = " DeepWrap is thinking",
|
|
736
|
+
style = "bold #60a5fa",
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
parts.append(Text(""))
|
|
740
|
+
|
|
741
|
+
if answer:
|
|
742
|
+
parts.append(Text("DeepWrap", style = "bold #3b82f6"))
|
|
743
|
+
parts.append(Markdown(answer))
|
|
744
|
+
else:
|
|
745
|
+
parts.append(Text("DeepWrap", style = "bold #3b82f6"))
|
|
746
|
+
|
|
747
|
+
return Group(*parts)
|
|
748
|
+
|
|
749
|
+
def _print_assistant_final(self, thinking: str, answer: str) -> None:
|
|
750
|
+
"""
|
|
751
|
+
Print the final completed assistant output as normal static content.
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
self.console.print()
|
|
755
|
+
|
|
756
|
+
if self.config.show_thinking and thinking:
|
|
757
|
+
self.console.print(Text("thinking", style = "bold #60a5fa"))
|
|
758
|
+
self.console.print(Text(thinking, style = "dim #94a3b8"))
|
|
759
|
+
self.console.print()
|
|
760
|
+
|
|
761
|
+
self.console.print(Text("DeepWrap", style = "bold #3b82f6"))
|
|
762
|
+
|
|
763
|
+
if answer.strip():
|
|
764
|
+
self.console.print(Markdown(answer))
|
|
765
|
+
else:
|
|
766
|
+
self.console.print(Text("(empty response)", style = "dim"))
|
|
767
|
+
|
|
768
|
+
self.console.print()
|
|
769
|
+
|
|
770
|
+
def _render_header(self) -> None:
|
|
771
|
+
"""
|
|
772
|
+
Render the top banner.
|
|
773
|
+
"""
|
|
774
|
+
|
|
775
|
+
banner = Text()
|
|
776
|
+
banner.append("██████╗ ███████╗███████╗██████╗ ██╗ ██╗██████╗ █████╗ ██████╗ \n", style = "bold #3b82f6")
|
|
777
|
+
banner.append("██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗\n", style = "bold #3b82f6")
|
|
778
|
+
banner.append("██║ ██║█████╗ █████╗ ██████╔╝██║ █╗ ██║██████╔╝███████║██████╔╝\n", style = "bold #3b82f6")
|
|
779
|
+
banner.append("██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║███╗██║██╔══██╗██╔══██║██╔═══╝ \n", style = "bold #3b82f6")
|
|
780
|
+
banner.append("██████╔╝███████╗███████╗██║ ╚███╔███╔╝██║ ██║██║ ██║██║ \n", style = "bold #3b82f6")
|
|
781
|
+
banner.append("╚═════╝ ╚══════╝╚══════╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ", style = "bold #3b82f6")
|
|
782
|
+
|
|
783
|
+
self.console.print()
|
|
784
|
+
self.console.print(banner)
|
|
785
|
+
self.console.print()
|
|
786
|
+
|
|
787
|
+
self.console.print(
|
|
788
|
+
Text(
|
|
789
|
+
"Do not pay in API calls for what a bad prompt can sabotage for free.",
|
|
790
|
+
style = "#94a3b8",
|
|
791
|
+
)
|
|
792
|
+
)
|
|
793
|
+
self.console.print(
|
|
794
|
+
Text("Type ", style = "#94a3b8")
|
|
795
|
+
+ Text("/help", style = "bold #60a5fa")
|
|
796
|
+
+ Text(" for commands.", style = "#94a3b8")
|
|
797
|
+
)
|
|
798
|
+
self.console.print()
|
|
799
|
+
|
|
800
|
+
def _render_intro(self) -> None:
|
|
801
|
+
"""
|
|
802
|
+
Render current runtime state summary.
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
token_state = "set" if self.config.token else "missing"
|
|
806
|
+
|
|
807
|
+
self.console.print(Rule(style = "#1e3a8a"))
|
|
808
|
+
self.console.print(
|
|
809
|
+
Text(
|
|
810
|
+
f"model={self.config.model} | token={token_state} | thinking={'on' if self.config.show_thinking else 'off'} | search={'on' if self.config.search_enabled else 'off'} | god={'on' if self.config.god_mode else 'off'}",
|
|
811
|
+
style = "#93c5fd",
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
self.console.print()
|
|
815
|
+
|
|
816
|
+
def _render_help(self) -> None:
|
|
817
|
+
"""
|
|
818
|
+
Render command help without borders.
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
self.console.print()
|
|
822
|
+
self.console.print(Text("help", style = "bold #60a5fa"))
|
|
823
|
+
|
|
824
|
+
for command, description in HELP_ITEMS:
|
|
825
|
+
line = Text()
|
|
826
|
+
line.append(" ")
|
|
827
|
+
line.append(command.ljust(20), style = "bold #bfdbfe")
|
|
828
|
+
line.append(description, style = "#cbd5e1")
|
|
829
|
+
self.console.print(line)
|
|
830
|
+
|
|
831
|
+
self.console.print()
|
|
832
|
+
|
|
833
|
+
def _render_status(self) -> None:
|
|
834
|
+
"""
|
|
835
|
+
Render current runtime status without borders.
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
lines = [
|
|
839
|
+
f"model: {self.config.model}",
|
|
840
|
+
f"token: {'set' if self.config.token else 'missing'}",
|
|
841
|
+
f"thinking display: {'on' if self.config.show_thinking else 'off'}",
|
|
842
|
+
f"search: {'on' if self.config.search_enabled else 'off'}",
|
|
843
|
+
f"god mode: {'on' if self.config.god_mode else 'off'}",
|
|
844
|
+
f"chat session: {'active' if self.chat is not None else 'none'}",
|
|
845
|
+
f"config file: {self.store.path}",
|
|
846
|
+
]
|
|
847
|
+
|
|
848
|
+
self.console.print()
|
|
849
|
+
self.console.print(Text("status", style = "bold #60a5fa"))
|
|
850
|
+
self.console.print(Text("\n".join(lines), style = "#dbeafe"))
|
|
851
|
+
self.console.print()
|
|
852
|
+
|
|
853
|
+
def _render_user(self, message: str) -> None:
|
|
854
|
+
"""
|
|
855
|
+
Render a user message once, with a subtle blue background.
|
|
856
|
+
"""
|
|
857
|
+
|
|
858
|
+
name = Text(" You ", style = "bold white on #2563eb")
|
|
859
|
+
body = Text(f" {message} ", style = "#dbeafe on #172554")
|
|
860
|
+
|
|
861
|
+
self.console.print()
|
|
862
|
+
self.console.print(name, end = " ")
|
|
863
|
+
self.console.print(body)
|
|
864
|
+
self.console.print()
|
|
865
|
+
|
|
866
|
+
def _print_system(self, message: str, style: str = "#94a3b8") -> None:
|
|
867
|
+
"""
|
|
868
|
+
Render a system/status message.
|
|
869
|
+
"""
|
|
870
|
+
|
|
871
|
+
self.console.print(Text(message, style = style))
|
|
872
|
+
|
|
873
|
+
def _prompt_text(self):
|
|
874
|
+
"""
|
|
875
|
+
Return the prompt text shown for user input.
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
return [("#3b82f6 bold", "> ")]
|
|
879
|
+
|
|
880
|
+
def _boot_client(self, reset_chat: bool = True) -> None:
|
|
881
|
+
"""
|
|
882
|
+
Create the root client from the current token.
|
|
883
|
+
"""
|
|
884
|
+
|
|
885
|
+
if not self.config.token:
|
|
886
|
+
self.client = None
|
|
887
|
+
self.chat = None
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
self.client = Client(api_key = self.config.token)
|
|
892
|
+
|
|
893
|
+
if reset_chat:
|
|
894
|
+
self._create_chat()
|
|
895
|
+
|
|
896
|
+
except Exception as exc:
|
|
897
|
+
self._print_system(f"Failed to initialize client: {exc}", style = "red")
|
|
898
|
+
|
|
899
|
+
if 'invalid token' in str(exc).lower():
|
|
900
|
+
self._clear_screen()
|
|
901
|
+
self._render_header()
|
|
902
|
+
self._render_intro()
|
|
903
|
+
self._print_system("The provided token is invalid. Please set a valid token.", style = "red")
|
|
904
|
+
self._offer_token_setup()
|
|
905
|
+
|
|
906
|
+
def _create_chat(self) -> None:
|
|
907
|
+
"""
|
|
908
|
+
Create a new chat session using the current model and God Mode setting.
|
|
909
|
+
"""
|
|
910
|
+
|
|
911
|
+
if self.client is None:
|
|
912
|
+
self.chat = None
|
|
913
|
+
return
|
|
914
|
+
|
|
915
|
+
self.chat = self.client.chats.create_session(
|
|
916
|
+
model = self.config.model,
|
|
917
|
+
god_mode = self.config.god_mode,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
def _ensure_chat(self) -> bool:
|
|
921
|
+
"""
|
|
922
|
+
Ensure a chat session exists before sending a prompt.
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
if self.client is None:
|
|
926
|
+
self._offer_token_setup()
|
|
927
|
+
|
|
928
|
+
if self.client is None:
|
|
929
|
+
self._print_system(
|
|
930
|
+
"No token configured. Use /token first.",
|
|
931
|
+
style = "yellow",
|
|
932
|
+
)
|
|
933
|
+
return False
|
|
934
|
+
|
|
935
|
+
if self.chat is None:
|
|
936
|
+
self._create_chat()
|
|
937
|
+
|
|
938
|
+
return self.chat is not None
|
|
939
|
+
|
|
940
|
+
def _ask_yes_no(self, prompt: str, default: bool) -> bool:
|
|
941
|
+
"""
|
|
942
|
+
Ask a yes/no question interactively.
|
|
943
|
+
"""
|
|
944
|
+
|
|
945
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
946
|
+
reply = self.prompt.prompt(f"{prompt} {suffix} ").strip().lower()
|
|
947
|
+
|
|
948
|
+
if not reply:
|
|
949
|
+
return default
|
|
950
|
+
|
|
951
|
+
return reply in {"y", "yes"}
|
|
952
|
+
|
|
953
|
+
def _clear_screen(self) -> None:
|
|
954
|
+
"""
|
|
955
|
+
Clear the terminal screen.
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
self.console.clear()
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def main() -> None:
|
|
962
|
+
"""
|
|
963
|
+
CLI entrypoint.
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
DeepWrapCLI().run()
|