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 +40 -0
- utim_cli/agent.py +359 -0
- utim_cli/auth.py +208 -0
- utim_cli/backup.py +101 -0
- utim_cli/billing.py +40 -0
- utim_cli/blender_agent.py +1018 -0
- utim_cli/bootstrap.py +324 -0
- utim_cli/client_utils.py +135 -0
- utim_cli/config.py +194 -0
- utim_cli/context_pruner.py +504 -0
- utim_cli/doctor.py +118 -0
- utim_cli/knowledge_graph.py +462 -0
- utim_cli/logger.py +121 -0
- utim_cli/mcp_clean_wrapper.py +55 -0
- utim_cli/mcp_client.py +198 -0
- utim_cli/mcp_registry.json +1102 -0
- utim_cli/orchestrator.py +3209 -0
- utim_cli/reflection.py +200 -0
- utim_cli/report.py +100 -0
- utim_cli/scrapy_search.py +229 -0
- utim_cli/share.py +320 -0
- utim_cli/share_tui.py +554 -0
- utim_cli/situational_scoring.py +269 -0
- utim_cli/state.py +15 -0
- utim_cli/tools.py +3381 -0
- utim_cli/utim.py +4051 -0
- utim_cli/vector_memory.py +629 -0
- utim_cli/workspace.py +33 -0
- utim_cli-1.0.0.dist-info/METADATA +134 -0
- utim_cli-1.0.0.dist-info/RECORD +34 -0
- utim_cli-1.0.0.dist-info/WHEEL +5 -0
- utim_cli-1.0.0.dist-info/entry_points.txt +2 -0
- utim_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- utim_cli-1.0.0.dist-info/top_level.txt +1 -0
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>"""
|