utim-cli 1.0.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.
utim_cli/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ # UTIM CLI Package
2
+ import builtins, os, pathlib, urllib.parse, re
3
+
4
+ def _make_file_uri(path: str) -> str:
5
+ p = pathlib.Path(path).resolve()
6
+ encoded = urllib.parse.quote(str(p).replace('\\', '/'))
7
+ return f"file:///{encoded}"
8
+
9
+ def _maybe_path_to_uri(text: str) -> str:
10
+ # Detect absolute Windows paths (C:\... or /c/... )
11
+ if re.match(r"^[a-zA-Z]:[\\/].*", text) and os.path.exists(text):
12
+ return f"{text} ({_make_file_uri(text)})"
13
+ # Detect relative paths that exist in cwd
14
+ rel = os.path.abspath(text)
15
+ if os.path.exists(rel):
16
+ return f"{text} ({_make_file_uri(rel)})"
17
+ return text
18
+
19
+ _orig_print = builtins.print
20
+
21
+ def _utim_print(*args, **kwargs):
22
+ formatted = [_maybe_path_to_uri(str(a)) for a in args]
23
+ try:
24
+ _orig_print(*formatted, **kwargs)
25
+ except UnicodeEncodeError:
26
+ import sys
27
+ encoding = sys.stdout.encoding or 'cp1252'
28
+ safe_formatted = []
29
+ for item in formatted:
30
+ safe_item = item.encode(encoding, errors='backslashreplace').decode(encoding)
31
+ safe_formatted.append(safe_item)
32
+ try:
33
+ _orig_print(*safe_formatted, **kwargs)
34
+ except Exception:
35
+ # Absolute fallback to standard ASCII if everything else fails
36
+ ascii_formatted = [item.encode('ascii', errors='replace').decode('ascii') for item in formatted]
37
+ _orig_print(*ascii_formatted, **kwargs)
38
+
39
+ # Override the global print for this package
40
+ builtins.print = _utim_print
utim_cli/agent.py ADDED
@@ -0,0 +1,359 @@
1
+ import os
2
+ import json
3
+ import sys
4
+ import time
5
+ from openai import OpenAI
6
+ from .tools import UTIM_TOOLS, TOOL_FUNCTIONS
7
+ from rich.console import Console
8
+ from rich.live import Live
9
+ from rich.spinner import Spinner
10
+ from rich.markdown import Markdown
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+ from rich.rule import Rule
14
+ from rich.syntax import Syntax
15
+
16
+ # ─── Tool display metadata ────────────────────────────────────────────────────
17
+ # Color constants for consolidated 3-color palette
18
+ PURPLE = "#cba6f7"
19
+ BLUE = "#42bcf5"
20
+ YELLOW = "#f9e2af"
21
+
22
+ TOOL_META = {
23
+ "read_file": {"icon": "📄", "verb": "ReadFile", "color": BLUE},
24
+ "write_file": {"icon": "✏️", "verb": "WriteFile", "color": YELLOW},
25
+ "edit_file": {"icon": "✂️", "verb": "EditFile", "color": YELLOW},
26
+ "move_file": {"icon": "📦", "verb": "MoveFile", "color": BLUE},
27
+ "delete_file": {"icon": "🗑️ ", "verb": "DeleteFile", "color": PURPLE},
28
+ "run_command": {"icon": "⚡", "verb": "RunCommand", "color": YELLOW},
29
+ "list_directory": {"icon": "📁", "verb": "ReadFolder", "color": PURPLE},
30
+ "blender_create_object": {"icon": "🧊", "verb": "Create3D", "color": PURPLE},
31
+ }
32
+
33
+ TIPS = [
34
+ "Tip: Copy the last response to your clipboard with /copy",
35
+ "Tip: Save your current conversation with /resume save <tag>",
36
+ "Tip: Clear conversation history with /clear",
37
+ "Tip: Check your credit balance with /balance",
38
+ "Tip: List available tools with /tools",
39
+ "Tip: Switch models with /model",
40
+ ]
41
+
42
+ class ReActAgent:
43
+ """A standalone agent that can reason and execute tools with streaming."""
44
+
45
+ def __init__(self, name: str, model_id: str, system_prompt: str, console: Console = None):
46
+ self.name = name
47
+ self.model_id = model_id
48
+ self.console = console or Console()
49
+ self._tip_index = 0
50
+
51
+ api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENRouter_API_KEY")
52
+ if not api_key:
53
+ self.console.print("[dim]⚠ Warning: OPENROUTER_API_KEY is not set.[/dim]")
54
+
55
+ self.client = OpenAI(
56
+ base_url="https://openrouter.ai/api/v1",
57
+ api_key=api_key or "sk-fake-key-for-init",
58
+ )
59
+ self.messages = [{"role": "system", "content": system_prompt}]
60
+ self.start_time = None
61
+
62
+ def add_user_message(self, message: str):
63
+ self.messages.append({"role": "user", "content": message})
64
+
65
+ def get_elapsed_time(self):
66
+ if self.start_time is None:
67
+ return "0s"
68
+ elapsed = int(time.time() - self.start_time)
69
+ return f"{elapsed // 60}m {elapsed % 60}s" if elapsed >= 60 else f"{elapsed}s"
70
+
71
+ def _next_tip(self):
72
+ tip = TIPS[self._tip_index % len(TIPS)]
73
+ self._tip_index += 1
74
+ return tip
75
+
76
+ def _render_tool_panel(self, func_name: str, args: dict, result: str):
77
+ """Render a rich panel for a completed tool call."""
78
+ meta = TOOL_META.get(func_name, {"icon": "●", "verb": func_name, "color": "white"})
79
+ icon = meta["icon"]
80
+ verb = meta["verb"]
81
+ color = meta["color"]
82
+
83
+ # Header line
84
+ header = Text()
85
+ header.append("✓ ", style=f"bold {color}")
86
+ header.append(verb, style=f"bold {color}")
87
+
88
+ # Show the primary argument (filepath or command)
89
+ primary_arg = (
90
+ args.get("filepath")
91
+ or args.get("command")
92
+ or args.get("path")
93
+ or args.get("src")
94
+ or ""
95
+ )
96
+ if primary_arg:
97
+ header.append(f" {primary_arg}", style="white")
98
+
99
+ # Body
100
+ body = Text()
101
+
102
+ if func_name == "write_file":
103
+ body.append(result, style="dim")
104
+
105
+ elif func_name == "edit_file":
106
+ body.append(result, style="dim")
107
+
108
+ elif func_name == "read_file":
109
+ lines = result.split("\n")
110
+ body.append(f"Read {len(lines)} lines", style="dim")
111
+
112
+ elif func_name == "run_command":
113
+ out = str(result).strip()
114
+ if len(out) > 300:
115
+ out = out[:300] + "\n[…truncated]"
116
+ body.append(out, style="dim")
117
+
118
+ elif func_name == "list_directory":
119
+ items = [l for l in str(result).split("\n") if l.strip()]
120
+ body.append(f"Listed {max(0, len(items)-1)} item(s).", style="dim")
121
+
122
+ elif func_name == "move_file":
123
+ body.append(result, style="dim")
124
+
125
+ elif func_name == "delete_file":
126
+ body.append(result, style="dim")
127
+
128
+ else:
129
+ out = str(result).strip()
130
+ if len(out) > 300:
131
+ out = out[:300] + "\n[…]"
132
+ body.append(out, style="dim")
133
+
134
+ content = header + "\n" + body
135
+
136
+ self.console.print(Panel(
137
+ content,
138
+ border_style=color,
139
+ expand=False,
140
+ ))
141
+
142
+ def _execute_tool_call(self, tool_call):
143
+ """Execute a single tool call by name."""
144
+ tool_name = tool_call["function"]["name"]
145
+
146
+ # Clean corrupted tool name (e.g. from buggy OpenRouter proxy XML to tool-call translations)
147
+ # E.g. 'read_file filepath=".utim/UTIM.md" />'
148
+ arguments = {}
149
+ raw_args = tool_call["function"].get("arguments", "{}")
150
+ if raw_args:
151
+ try:
152
+ arguments = json.loads(raw_args)
153
+ if not isinstance(arguments, dict):
154
+ arguments = {}
155
+ except Exception:
156
+ pass
157
+
158
+ tool_name_clean = tool_name.strip("<> ")
159
+ if tool_name_clean:
160
+ parts = tool_name_clean.split(None, 1)
161
+ actual_name = parts[0]
162
+ if len(parts) > 1:
163
+ attr_string = parts[1].rstrip("/> ")
164
+ import re
165
+ attrs = re.findall(r'(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))', attr_string)
166
+ for key, val1, val2, val3 in attrs:
167
+ val = val1 or val2 or val3 or ""
168
+ arguments[key] = val
169
+ tool_name = actual_name
170
+
171
+ # Update the tool_call dict back with the cleaned values
172
+ tool_call["function"]["name"] = tool_name
173
+ tool_call["function"]["arguments"] = json.dumps(arguments)
174
+
175
+ arguments = json.loads(tool_call["function"]["arguments"])
176
+
177
+ # Show the tool is running
178
+ meta = TOOL_META.get(tool_name, {"icon": "●", "verb": tool_name, "color": "white"})
179
+ self.console.print(f" {meta['icon']} {meta['verb']} running...", style=f"dim {meta['color']}")
180
+
181
+ if "__" in tool_name:
182
+ server_name, actual_tool_name = tool_name.split("__", 1)
183
+ try:
184
+ from utim_cli.mcp_client import mcp_manager
185
+ if server_name in mcp_manager.sessions:
186
+ self.console.print(f" 🔌 Calling MCP tool {server_name} ➔ {actual_tool_name}...", style="dim #cba6f7")
187
+ result = mcp_manager.call_tool(server_name, actual_tool_name, arguments)
188
+
189
+ # Temporarily register metadata for render
190
+ TOOL_META[tool_name] = {"icon": "🔌", "verb": f"Calling {server_name} ➔ {actual_tool_name}", "color": "#cba6f7"}
191
+ self._render_tool_panel(tool_name, arguments, result)
192
+ return str(result)
193
+ except Exception as e:
194
+ return f"Error executing MCP tool {tool_name}: {str(e)}"
195
+
196
+ if tool_name not in TOOL_FUNCTIONS:
197
+ return f"Unknown tool: {tool_name}"
198
+
199
+ try:
200
+ result = TOOL_FUNCTIONS[tool_name](**arguments)
201
+ self._render_tool_panel(tool_name, arguments, result)
202
+ return str(result)
203
+ except Exception as e:
204
+ return f"Error executing {tool_name}: {str(e)}"
205
+
206
+ def run(self, max_iterations: int = 500, show_tools: bool = True):
207
+ """Run the agent loop with streaming."""
208
+ self.start_time = time.time()
209
+
210
+ for i in range(max_iterations):
211
+ assistant_content = ""
212
+ tool_calls = []
213
+ current_tool_call = None
214
+
215
+ mcp_tools = []
216
+ try:
217
+ from utim_cli.mcp_client import mcp_manager
218
+ mcp_tools = mcp_manager.get_tools()
219
+ except Exception:
220
+ pass
221
+
222
+ # Filter disabled tools
223
+ from utim_cli.config import config
224
+ disabled = config.get("disabled_tools", [])
225
+ all_tools = [t for t in (UTIM_TOOLS + mcp_tools) if t["function"]["name"] not in disabled]
226
+
227
+ kwargs = {
228
+ "model": self.model_id,
229
+ "messages": self.messages,
230
+ "stream": True,
231
+ }
232
+ if all_tools:
233
+ kwargs["tools"] = all_tools
234
+
235
+ # Call the LLM with streaming
236
+ stream = self.client.chat.completions.create(**kwargs)
237
+
238
+ # Collect streaming chunks
239
+ for chunk in stream:
240
+ delta = chunk.choices[0].delta
241
+
242
+ if delta.content:
243
+ assistant_content += delta.content
244
+
245
+ if delta.tool_calls:
246
+ for tc in delta.tool_calls:
247
+ tc_index = tc.index if tc.index is not None else 0
248
+ if current_tool_call is None or tc_index != current_tool_call.get("index"):
249
+ # Flush the previous buffered tool call (check index, not id)
250
+ if current_tool_call is not None:
251
+ tool_calls.append(current_tool_call)
252
+ current_tool_call = {
253
+ "index": tc_index,
254
+ "id": tc.id or "",
255
+ "type": tc.type or "function",
256
+ "function": {
257
+ "name": tc.function.name if tc.function else "",
258
+ "arguments": tc.function.arguments if tc.function else "",
259
+ },
260
+ }
261
+ else:
262
+ # Same tool call — accumulate the argument chunks
263
+ if tc.id and not current_tool_call["id"]:
264
+ current_tool_call["id"] = tc.id
265
+ if tc.function and tc.function.arguments:
266
+ current_tool_call["function"]["arguments"] += tc.function.arguments
267
+
268
+ # Flush the last buffered tool call
269
+ if current_tool_call is not None:
270
+ tool_calls.append(current_tool_call)
271
+
272
+ # Print any text content generated by the assistant
273
+ if assistant_content.strip():
274
+ self.console.print()
275
+ self.console.print(Markdown(assistant_content))
276
+ # Only add a trailing newline if there are no tool calls following
277
+ if not tool_calls:
278
+ self.console.print()
279
+
280
+ # If no tool calls, we are done
281
+ if not tool_calls:
282
+ break
283
+
284
+ # Save the assistant message with tool_calls
285
+ self.messages.append({
286
+ "role": "assistant",
287
+ "content": assistant_content if assistant_content else None,
288
+ "tool_calls": tool_calls,
289
+ })
290
+
291
+ # Execute each tool call
292
+ for tc in tool_calls:
293
+ result = self._execute_tool_call(tc)
294
+
295
+ # Add tool result to messages
296
+ self.messages.append({
297
+ "role": "tool",
298
+ "tool_call_id": tc.get("id", ""),
299
+ "content": result,
300
+ })
301
+ else:
302
+ self.console.print(f"\n[bold yellow]⚠ Agent paused after reaching maximum iterations ({max_iterations}).[/bold yellow]")
303
+ self.console.print("[dim]You can type 'continue' to resume the task.[/dim]\n")
304
+
305
+ elapsed = self.get_elapsed_time()
306
+ tip = self._next_tip()
307
+ self.console.print(Rule(f"[dim]⚙ {elapsed} • {tip}[/dim]"))
308
+
309
+ def list_tools(self):
310
+ """Display all available tools in a formatted table."""
311
+ self.console.print()
312
+ self.console.print(Rule("[bold accent]🔧 Available Tools[/bold accent]"))
313
+ self.console.print()
314
+
315
+ headers = ["Tool", "Description"]
316
+ all_tools = []
317
+ for tool_def in UTIM_TOOLS:
318
+ fn = tool_def["function"]
319
+ all_tools.append((fn["name"], fn["description"], "standard"))
320
+
321
+ try:
322
+ from utim_cli.mcp_client import mcp_manager
323
+ mcp_tools = mcp_manager.get_tools()
324
+ for t in mcp_tools:
325
+ fn = t["function"]
326
+ all_tools.append((fn["name"], fn["description"], "mcp"))
327
+ except Exception:
328
+ pass
329
+
330
+ if not all_tools:
331
+ return
332
+
333
+ # Load disabled tools config
334
+ from utim_cli.config import config
335
+ disabled = config.get("disabled_tools", [])
336
+
337
+ col_widths = [max(len(t[0]) for t in all_tools) + 2, 60]
338
+
339
+ # Header
340
+ header_str = f" {headers[0].ljust(col_widths[0])} {headers[1]}"
341
+ self.console.print(f"[bold accent]{header_str}[/bold accent]")
342
+ self.console.print(Rule(style="dim"))
343
+
344
+ for name, desc, t_type in all_tools:
345
+ is_disabled = name in disabled
346
+ status_tag = " [red][Disabled][/red]" if is_disabled else ""
347
+ desc_str = f"{status_tag} [dim]{desc}[/dim]" if is_disabled else f"[dim]{desc}[/dim]"
348
+
349
+ if t_type == "mcp":
350
+ tool_str = f" 🔌 {name.ljust(col_widths[0] - 4)} {desc_str}"
351
+ self.console.print(f"[#cba6f7]{tool_str}[/#cba6f7]")
352
+ else:
353
+ meta = TOOL_META.get(name, {"icon": "●", "color": "white"})
354
+ # Dim the icon and name color if disabled
355
+ color = "dim" if is_disabled else meta['color']
356
+ tool_str = f" {meta['icon']} {name.ljust(col_widths[0] - 4)} {desc_str}"
357
+ self.console.print(f"[{color}]{tool_str}[/{color}]")
358
+
359
+ self.console.print()
utim_cli/auth.py ADDED
@@ -0,0 +1,208 @@
1
+ """
2
+ UTIM CLI — Authentication
3
+
4
+ Flow:
5
+ 1. Open browser → Firebase auth page (hosted by the UTIM web app or
6
+ directly via firebaseui / identitytoolkit redirect)
7
+ 2. After sign-in, Firebase redirects to localhost:31415/auth/callback
8
+ with ?token=<firebase_id_token>&email=<email>&uid=<uid>&name=<name>
9
+ 3. CLI POSTs the Firebase ID token to the Railway server
10
+ POST https://utim-cli-production.up.railway.app/auth/firebase-login
11
+ 4. Server verifies the token, provisions the user, returns api_key
12
+ 5. CLI stores api_key in local config — used as X-API-Key forever after
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import http.server
17
+ import socketserver
18
+ import threading
19
+ import urllib.parse
20
+ import webbrowser
21
+ from typing import Optional
22
+
23
+ import os
24
+ import requests
25
+ from rich.console import Console
26
+
27
+ from .config import config
28
+
29
+ # ── Constants ─────────────────────────────────────────────────────────────────
30
+
31
+ PURPLE = "#cba6f7"
32
+ BLUE = "#42bcf5"
33
+ YELLOW = "#f9e2af"
34
+
35
+ # Production server — all auth calls go here
36
+ SERVER_URL = os.environ.get("UTIM_SERVER_URL", "https://utim-cli-production.up.railway.app")
37
+ WEB_URL = os.environ.get("UTIM_WEB_URL", "https://utim.dev")
38
+
39
+ # Firebase project config (public — safe to embed in CLI)
40
+ FIREBASE_PROJECT_ID = "u-t-i-m-39c26"
41
+ FIREBASE_API_KEY = "AIzaSyAV-L3jY6dS3wXMMNGnYnPTX3IuqBFqK4E"
42
+ FIREBASE_AUTH_DOMAIN = "u-t-i-m-39c26.firebaseapp.com"
43
+
44
+ console = Console()
45
+
46
+ # ── Callback HTTP handler ─────────────────────────────────────────────────────
47
+
48
+ class _AuthCallbackHandler(http.server.BaseHTTPRequestHandler):
49
+ """Tiny local server that catches the Firebase redirect and closes itself."""
50
+
51
+ # Shared state written by do_GET, read by login()
52
+ received: dict = {}
53
+
54
+ def log_message(self, *args): # silence stdlib HTTP logs
55
+ pass
56
+
57
+ def do_GET(self):
58
+ query = urllib.parse.urlparse(self.path).query
59
+ params = urllib.parse.parse_qs(query)
60
+
61
+ token = params.get("token", [None])[0]
62
+ email = params.get("email", [None])[0]
63
+ uid = params.get("uid", [None])[0]
64
+ name = params.get("name", [None])[0]
65
+
66
+ if token and email:
67
+ _AuthCallbackHandler.received = {
68
+ "token": token,
69
+ "email": email,
70
+ "uid": uid or "",
71
+ "name": name or email.split("@")[0],
72
+ }
73
+ self._send_html(_SUCCESS_HTML)
74
+ else:
75
+ self._send_html(_FAILURE_HTML, status=400)
76
+
77
+ # Shut down the local server from a background thread
78
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
79
+
80
+ def _send_html(self, body: str, status: int = 200):
81
+ encoded = body.encode("utf-8")
82
+ self.send_response(status)
83
+ self.send_header("Content-Type", "text/html; charset=utf-8")
84
+ self.send_header("Content-Length", str(len(encoded)))
85
+ self.end_headers()
86
+ self.wfile.write(encoded)
87
+
88
+
89
+ # ── Public login() function ───────────────────────────────────────────────────
90
+
91
+ def login() -> None:
92
+ """
93
+ Open the browser for Firebase authentication and exchange the resulting
94
+ ID token for a UTIM API key from the production server.
95
+ """
96
+ _AuthCallbackHandler.received = {}
97
+ port = 31415
98
+
99
+ socketserver.TCPServer.allow_reuse_address = True
100
+ httpd = socketserver.TCPServer(("", port), _AuthCallbackHandler)
101
+
102
+ callback_url = f"http://localhost:{port}/auth/callback"
103
+
104
+ # Build the auth redirect URL pointing to our website auth page.
105
+ auth_url = (
106
+ f"{WEB_URL}/auth"
107
+ f"?callback={urllib.parse.quote(callback_url)}"
108
+ )
109
+
110
+ console.print(f"\n [bold {PURPLE}]Opening browser for sign-in…[/bold {PURPLE}]")
111
+ console.print(f" [dim]If the browser doesn't open, visit:[/dim]")
112
+ console.print(f" [bold {BLUE}]{auth_url}[/bold {BLUE}]\n")
113
+ webbrowser.open(auth_url)
114
+
115
+ # Block until the callback arrives
116
+ httpd.serve_forever()
117
+ httpd.server_close()
118
+
119
+ data = _AuthCallbackHandler.received
120
+ if not data.get("token"):
121
+ console.print(f" [bold red]✗ Authentication cancelled or failed.[/bold red]\n")
122
+ return
123
+
124
+ # Exchange Firebase ID token → UTIM API key
125
+ console.print(f" [dim]Verifying with UTIM server…[/dim]")
126
+ try:
127
+ resp = requests.post(
128
+ f"{SERVER_URL}/auth/firebase-login",
129
+ json={"id_token": data["token"]},
130
+ timeout=15,
131
+ )
132
+ resp.raise_for_status()
133
+ payload = resp.json()
134
+ except Exception as exc:
135
+ console.print(f" [bold red]✗ Server error: {exc}[/bold red]\n")
136
+ return
137
+
138
+ # Persist credentials
139
+ config.set("token", data["token"])
140
+ config.set("email", payload["email"])
141
+ config.set("uid", data["uid"])
142
+ config.set("name", payload.get("display_name", data["name"]))
143
+ config.set("api_key", payload["api_key"])
144
+
145
+ greeting = "Welcome! Your account has been created." if payload.get("is_new_user") else "Welcome back!"
146
+ console.print(
147
+ f" [bold {YELLOW}]✓ {greeting}[/bold {YELLOW}]\n"
148
+ f" [dim]Signed in as[/dim] [bold {BLUE}]{payload['email']}[/bold {BLUE}]\n"
149
+ f" [dim]Credits:[/dim] [bold]{payload['credits']:,.0f} UTIM[/bold]\n"
150
+ )
151
+
152
+
153
+ # ── HTML templates ────────────────────────────────────────────────────────────
154
+
155
+ _SUCCESS_HTML = """<!DOCTYPE html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="utf-8">
159
+ <title>UTIM CLI — Signed In</title>
160
+ <style>
161
+ *{box-sizing:border-box;margin:0;padding:0}
162
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
163
+ background:#0d1117;color:#c9d1d9;display:flex;align-items:center;
164
+ justify-content:center;height:100vh}
165
+ .card{background:#161b22;border:1px solid #30363d;border-radius:12px;
166
+ padding:48px 40px;max-width:420px;text-align:center;
167
+ box-shadow:0 16px 48px rgba(0,0,0,0.6)}
168
+ .icon{color:#3fb950;margin-bottom:24px}
169
+ .icon svg{width:72px;height:72px}
170
+ h1{color:#58a6ff;font-size:1.5rem;margin-bottom:12px}
171
+ p{color:#8b949e;line-height:1.6;font-size:0.95rem}
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div class="card">
176
+ <div class="icon">
177
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
178
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
179
+ <polyline points="20 6 9 17 4 12"/>
180
+ </svg>
181
+ </div>
182
+ <h1>Signed in successfully!</h1>
183
+ <p>You're authenticated with UTIM CLI.<br>You can close this tab and return to your terminal.</p>
184
+ </div>
185
+ </body>
186
+ </html>"""
187
+
188
+ _FAILURE_HTML = """<!DOCTYPE html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="utf-8">
192
+ <title>UTIM CLI — Auth Failed</title>
193
+ <style>
194
+ body{font-family:system-ui,sans-serif;background:#0d1117;color:#c9d1d9;
195
+ display:flex;align-items:center;justify-content:center;height:100vh}
196
+ .card{background:#161b22;border:1px solid #f85149;border-radius:12px;
197
+ padding:48px 40px;max-width:420px;text-align:center}
198
+ h1{color:#f85149;margin-bottom:12px}
199
+ p{color:#8b949e;line-height:1.6}
200
+ </style>
201
+ </head>
202
+ <body>
203
+ <div class="card">
204
+ <h1>Authentication Failed</h1>
205
+ <p>Missing token or email. Please try signing in again.</p>
206
+ </div>
207
+ </body>
208
+ </html>"""