utim-cli 1.44.5__tar.gz → 1.44.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.
- {utim_cli-1.44.5 → utim_cli-1.44.6}/MANIFEST.in +1 -2
- {utim_cli-1.44.5/utim_cli.egg-info → utim_cli-1.44.6}/PKG-INFO +1 -1
- {utim_cli-1.44.5 → utim_cli-1.44.6}/pyproject.toml +1 -1
- utim_cli-1.44.6/utim_cli/server/__init__.py +4 -0
- utim_cli-1.44.6/utim_cli/server/auth.py +294 -0
- utim_cli-1.44.6/utim_cli/server/db.py +605 -0
- utim_cli-1.44.6/utim_cli/server/email_utils.py +455 -0
- utim_cli-1.44.6/utim_cli/server/firebase.py +164 -0
- utim_cli-1.44.6/utim_cli/server/history.py +130 -0
- utim_cli-1.44.6/utim_cli/server/logging_config.py +79 -0
- utim_cli-1.44.6/utim_cli/server/models.py +998 -0
- utim_cli-1.44.6/utim_cli/server/rate_limit.py +4 -0
- utim_cli-1.44.6/utim_cli/server/router.py +334 -0
- utim_cli-1.44.6/utim_cli/server/routes/__init__.py +9 -0
- utim_cli-1.44.6/utim_cli/server/routes/auth_routes.py +275 -0
- utim_cli-1.44.6/utim_cli/server/routes/completion_routes.py +568 -0
- utim_cli-1.44.6/utim_cli/server/routes/credit_routes.py +238 -0
- utim_cli-1.44.6/utim_cli/server/routes/quota_routes.py +1250 -0
- utim_cli-1.44.6/utim_cli/server/routes/session_routes.py +176 -0
- utim_cli-1.44.6/utim_cli/server/routes/share_routes.py +154 -0
- utim_cli-1.44.6/utim_cli/server/server.py +56 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/utim.py +3 -3
- {utim_cli-1.44.5 → utim_cli-1.44.6/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli.egg-info/SOURCES.txt +18 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/CHANGELOG.md +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/LICENSE +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/README.md +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/setup.cfg +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/setup.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/__init__.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/agent.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/auth.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/backup.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/blender_agent.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/bootstrap.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/config.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/constants.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/context_pruner.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/doctor.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/logger.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/mcp_client.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/orchestrator.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/reflection.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/report.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/share.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/share_tui.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/state.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tools.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/model_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/vector_memory.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli/workspace.py +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-1.44.5 → utim_cli-1.44.6}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -10,8 +10,7 @@ include pyproject.toml
|
|
|
10
10
|
# ── Include the JSON data file bundled with the package ───────────────────────
|
|
11
11
|
include utim_cli/mcp_registry.json
|
|
12
12
|
|
|
13
|
-
# ── Exclude server sub-package (
|
|
14
|
-
prune utim_cli/server
|
|
13
|
+
# ── Exclude server sub-package (NO LONGER EXCLUDED - REQUIRED BY CLI HISTORY) ─────────
|
|
15
14
|
|
|
16
15
|
# ── Exclude tests, landing site, build artifacts ──────────────────────────────
|
|
17
16
|
prune tests
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.44.
|
|
7
|
+
version = "1.44.6"
|
|
8
8
|
description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UTIM Production Server — Auth Layer
|
|
3
|
+
API-key authentication using X-API-Key or Authorization: Bearer headers.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import uuid
|
|
10
|
+
import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, Header, HTTPException, status
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
from .db import User, Credit, UserSubscription, QuotaUsage, get_db
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("utim.auth")
|
|
19
|
+
|
|
20
|
+
# ── Master admin key (set in Railway env vars) ────────────────────────────────
|
|
21
|
+
MASTER_KEY = os.environ.get("UTIM_MASTER_KEY", None)
|
|
22
|
+
|
|
23
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def process_user_refills(db: Session, user_id: str) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Catch-up and process any pending 5-hour quota refills for the user's active subscription.
|
|
28
|
+
Max refills per 30-day billing cycle = 144.
|
|
29
|
+
"""
|
|
30
|
+
import datetime
|
|
31
|
+
import uuid
|
|
32
|
+
from .db import UserSubscription, Credit, Transaction
|
|
33
|
+
|
|
34
|
+
sub = db.query(UserSubscription).filter(
|
|
35
|
+
UserSubscription.user_id == user_id,
|
|
36
|
+
UserSubscription.status == "active"
|
|
37
|
+
).first()
|
|
38
|
+
|
|
39
|
+
now = datetime.datetime.utcnow()
|
|
40
|
+
|
|
41
|
+
if not sub or not sub.plan:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if sub.plan_id == "free":
|
|
45
|
+
# Handle monthly cycle end — reset the counter
|
|
46
|
+
if now >= sub.current_period_end:
|
|
47
|
+
sub.current_period_start = now
|
|
48
|
+
sub.current_period_end = now + datetime.timedelta(days=30)
|
|
49
|
+
sub.refills_processed = 0
|
|
50
|
+
|
|
51
|
+
elapsed_seconds = (now - sub.current_period_start).total_seconds()
|
|
52
|
+
max_due = int(elapsed_seconds // (5 * 3600))
|
|
53
|
+
|
|
54
|
+
due = max_due - sub.refills_processed
|
|
55
|
+
if due > 0:
|
|
56
|
+
credit = db.query(Credit).filter(Credit.user_id == user_id).first()
|
|
57
|
+
if credit:
|
|
58
|
+
# No rollover — each 5h slot resets the tank back to full 100.0
|
|
59
|
+
credit.balance = 100.0
|
|
60
|
+
sub.refills_processed = max_due
|
|
61
|
+
sub.last_refill_at = now
|
|
62
|
+
db.commit()
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Calculate how many 5-hour refills should have been processed by now
|
|
66
|
+
cycle_days = (sub.current_period_end - sub.current_period_start).days
|
|
67
|
+
is_yearly = cycle_days > 45
|
|
68
|
+
max_refills = 1728 if is_yearly else 144
|
|
69
|
+
|
|
70
|
+
if now >= sub.current_period_end:
|
|
71
|
+
max_due = max_refills
|
|
72
|
+
else:
|
|
73
|
+
elapsed_seconds = (now - sub.current_period_start).total_seconds()
|
|
74
|
+
max_due = min(max_refills, int(elapsed_seconds // (5 * 3600)))
|
|
75
|
+
|
|
76
|
+
due = max_due - sub.refills_processed
|
|
77
|
+
if due > 0:
|
|
78
|
+
plan = sub.plan
|
|
79
|
+
cycle_allowance = plan.credits_per_month / 144.0
|
|
80
|
+
|
|
81
|
+
# Unused quota from the first due cycle (which had sub.current_cycle_used accumulated)
|
|
82
|
+
# plus the full allowance of any subsequent due cycles where the user didn't make requests
|
|
83
|
+
current_cycle_used = sub.current_cycle_used or 0.0
|
|
84
|
+
unused_credits = max(0.0, cycle_allowance - current_cycle_used)
|
|
85
|
+
if due > 1:
|
|
86
|
+
unused_credits += cycle_allowance * (due - 1)
|
|
87
|
+
|
|
88
|
+
credit = db.query(Credit).filter(Credit.user_id == user_id).first()
|
|
89
|
+
if not credit:
|
|
90
|
+
credit = Credit(user_id=user_id, balance=0.0, total_spent=0.0, total_topped_up=0.0)
|
|
91
|
+
db.add(credit)
|
|
92
|
+
db.flush()
|
|
93
|
+
|
|
94
|
+
max_limit = plan.credits_per_month * 2.0
|
|
95
|
+
old_balance = credit.balance
|
|
96
|
+
new_balance = min(max_limit, credit.balance + unused_credits)
|
|
97
|
+
|
|
98
|
+
if new_balance > old_balance:
|
|
99
|
+
credit.balance = new_balance
|
|
100
|
+
tx = Transaction(
|
|
101
|
+
id=str(uuid.uuid4()),
|
|
102
|
+
user_id=user_id,
|
|
103
|
+
kind="refill",
|
|
104
|
+
amount=new_balance - old_balance,
|
|
105
|
+
balance_after=new_balance,
|
|
106
|
+
description=f"Refill of {due} cycles ({plan.name.title()} Plan)"
|
|
107
|
+
)
|
|
108
|
+
db.add(tx)
|
|
109
|
+
|
|
110
|
+
sub.current_cycle_used = 0.0
|
|
111
|
+
sub.refills_processed = max_due
|
|
112
|
+
sub.last_refill_at = now
|
|
113
|
+
db.commit()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_token(
|
|
117
|
+
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
|
118
|
+
authorization: Optional[str] = Header(None),
|
|
119
|
+
) -> Optional[str]:
|
|
120
|
+
"""Pull the raw key/token from headers."""
|
|
121
|
+
if x_api_key:
|
|
122
|
+
return x_api_key
|
|
123
|
+
if authorization:
|
|
124
|
+
parts = authorization.split()
|
|
125
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
126
|
+
return parts[1]
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_current_user(
|
|
131
|
+
token: Optional[str] = Depends(_extract_token),
|
|
132
|
+
db: Session = Depends(get_db),
|
|
133
|
+
) -> User:
|
|
134
|
+
"""
|
|
135
|
+
Resolve the authenticated user from an API key.
|
|
136
|
+
Raises 401 if missing / invalid.
|
|
137
|
+
"""
|
|
138
|
+
if not token:
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
141
|
+
detail="Missing API key. Pass X-API-Key or Authorization: Bearer <key>.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
user = db.query(User).filter(User.api_key == token, User.is_active == True).first()
|
|
145
|
+
if not user:
|
|
146
|
+
logger.warning("auth_failed", extra={"token_prefix": token[:8]})
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
149
|
+
detail="Invalid or revoked API key.",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
_bootstrap_welcome_email(db, user)
|
|
153
|
+
process_user_refills(db, user.id)
|
|
154
|
+
return user
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_admin_user(
|
|
158
|
+
token: Optional[str] = Depends(_extract_token),
|
|
159
|
+
db: Session = Depends(get_db),
|
|
160
|
+
) -> User:
|
|
161
|
+
"""Require the UTIM_MASTER_KEY for admin operations."""
|
|
162
|
+
if not MASTER_KEY:
|
|
163
|
+
raise HTTPException(status_code=500, detail="Master key not configured on server.")
|
|
164
|
+
if token != MASTER_KEY:
|
|
165
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
|
|
166
|
+
return None # admin endpoints don't return a user object
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_current_firebase_user(
|
|
170
|
+
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
|
171
|
+
authorization: Optional[str] = Header(None),
|
|
172
|
+
db: Session = Depends(get_db)
|
|
173
|
+
) -> User:
|
|
174
|
+
token = x_api_key
|
|
175
|
+
if not token and authorization:
|
|
176
|
+
parts = authorization.split()
|
|
177
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
178
|
+
token = parts[1]
|
|
179
|
+
|
|
180
|
+
if not token:
|
|
181
|
+
raise HTTPException(status_code=401, detail="Authorization token missing.")
|
|
182
|
+
|
|
183
|
+
# Check if token is a direct UTIM API Key
|
|
184
|
+
user_by_key = db.query(User).filter(User.api_key == token, User.is_active == True).first()
|
|
185
|
+
if user_by_key:
|
|
186
|
+
_bootstrap_welcome_email(db, user_by_key)
|
|
187
|
+
process_user_refills(db, user_by_key.id)
|
|
188
|
+
return user_by_key
|
|
189
|
+
|
|
190
|
+
# Verify as Firebase JWT Token
|
|
191
|
+
from .firebase import verify_firebase_token
|
|
192
|
+
try:
|
|
193
|
+
payload = verify_firebase_token(token)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
logger.warning(f"firebase_token_auth_failed: {exc}")
|
|
196
|
+
raise HTTPException(status_code=401, detail=str(exc))
|
|
197
|
+
|
|
198
|
+
email = payload.email or f"{payload.uid}@firebase.user"
|
|
199
|
+
user = db.query(User).filter(User.email == email).first()
|
|
200
|
+
if not user:
|
|
201
|
+
user = create_user(db, email=email, display_name=payload.name)
|
|
202
|
+
else:
|
|
203
|
+
_bootstrap_welcome_email(db, user)
|
|
204
|
+
process_user_refills(db, user.id)
|
|
205
|
+
return user
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Provisioning ──────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def create_user(db: Session, email: str, display_name: str = "") -> User:
|
|
212
|
+
"""Create a new user with initial credits and quota subscription. Idempotent by email."""
|
|
213
|
+
existing = db.query(User).filter(User.email == email).first()
|
|
214
|
+
if existing:
|
|
215
|
+
return existing
|
|
216
|
+
|
|
217
|
+
user = User(
|
|
218
|
+
id=str(uuid.uuid4()),
|
|
219
|
+
email=email,
|
|
220
|
+
display_name=display_name or email.split("@")[0],
|
|
221
|
+
api_key=f"utim-{uuid.uuid4().hex}",
|
|
222
|
+
is_active=True,
|
|
223
|
+
)
|
|
224
|
+
db.add(user)
|
|
225
|
+
db.flush() # get user.id
|
|
226
|
+
|
|
227
|
+
# Create credit row for backward compatibility
|
|
228
|
+
credit = Credit(user_id=user.id, balance=100.0, total_topped_up=0.0)
|
|
229
|
+
db.add(credit)
|
|
230
|
+
|
|
231
|
+
# Provision Free plan subscription
|
|
232
|
+
now = datetime.datetime.utcnow()
|
|
233
|
+
end_date = now + datetime.timedelta(days=30)
|
|
234
|
+
sub = UserSubscription(
|
|
235
|
+
user_id=user.id,
|
|
236
|
+
plan_id="free",
|
|
237
|
+
status="active",
|
|
238
|
+
current_period_start=now,
|
|
239
|
+
current_period_end=end_date,
|
|
240
|
+
)
|
|
241
|
+
db.add(sub)
|
|
242
|
+
|
|
243
|
+
# Create initial quota usage
|
|
244
|
+
quota = QuotaUsage(
|
|
245
|
+
user_id=user.id,
|
|
246
|
+
period_start=now,
|
|
247
|
+
period_end=end_date,
|
|
248
|
+
credits_used=0.0,
|
|
249
|
+
credits_limit=100, # Free limit: 100 credits
|
|
250
|
+
reset_at=end_date,
|
|
251
|
+
)
|
|
252
|
+
db.add(quota)
|
|
253
|
+
|
|
254
|
+
db.commit()
|
|
255
|
+
db.refresh(user)
|
|
256
|
+
|
|
257
|
+
_bootstrap_welcome_email(db, user)
|
|
258
|
+
|
|
259
|
+
logger.info("user_created", extra={"email": email, "user_id": user.id})
|
|
260
|
+
return user
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _bootstrap_welcome_email(db: Session, user: User) -> None:
|
|
264
|
+
"""Ensure user has an EmailTracking row and triggers their welcome email if not already sent."""
|
|
265
|
+
from .db import EmailTracking
|
|
266
|
+
tracking = db.query(EmailTracking).filter(EmailTracking.user_id == user.id).first()
|
|
267
|
+
if not tracking:
|
|
268
|
+
tracking = EmailTracking(
|
|
269
|
+
user_id=user.id,
|
|
270
|
+
email=user.email,
|
|
271
|
+
display_name=user.display_name or user.email.split("@")[0],
|
|
272
|
+
welcome_email_sent=False
|
|
273
|
+
)
|
|
274
|
+
db.add(tracking)
|
|
275
|
+
db.commit()
|
|
276
|
+
|
|
277
|
+
if not tracking.welcome_email_sent:
|
|
278
|
+
from .email_utils import send_welcome_email
|
|
279
|
+
import threading
|
|
280
|
+
def send_bg():
|
|
281
|
+
from .db import SessionLocal
|
|
282
|
+
thread_db = SessionLocal()
|
|
283
|
+
try:
|
|
284
|
+
success = send_welcome_email(user.email, user.display_name or user.email.split("@")[0])
|
|
285
|
+
if success:
|
|
286
|
+
thread_tracking = thread_db.query(EmailTracking).filter(EmailTracking.user_id == user.id).first()
|
|
287
|
+
if thread_tracking:
|
|
288
|
+
thread_tracking.welcome_email_sent = True
|
|
289
|
+
thread_db.commit()
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.error(f"Error sending welcome email in thread: {e}")
|
|
292
|
+
finally:
|
|
293
|
+
thread_db.close()
|
|
294
|
+
threading.Thread(target=send_bg, daemon=True).start()
|