utim-cli 1.44.5__tar.gz → 1.44.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {utim_cli-1.44.5 → utim_cli-1.44.7}/MANIFEST.in +1 -2
  2. {utim_cli-1.44.5/utim_cli.egg-info → utim_cli-1.44.7}/PKG-INFO +1 -1
  3. {utim_cli-1.44.5 → utim_cli-1.44.7}/pyproject.toml +1 -1
  4. utim_cli-1.44.7/utim_cli/server/__init__.py +4 -0
  5. utim_cli-1.44.7/utim_cli/server/auth.py +294 -0
  6. utim_cli-1.44.7/utim_cli/server/db.py +605 -0
  7. utim_cli-1.44.7/utim_cli/server/email_utils.py +455 -0
  8. utim_cli-1.44.7/utim_cli/server/firebase.py +164 -0
  9. utim_cli-1.44.7/utim_cli/server/history.py +130 -0
  10. utim_cli-1.44.7/utim_cli/server/logging_config.py +79 -0
  11. utim_cli-1.44.7/utim_cli/server/models.py +998 -0
  12. utim_cli-1.44.7/utim_cli/server/rate_limit.py +4 -0
  13. utim_cli-1.44.7/utim_cli/server/router.py +334 -0
  14. utim_cli-1.44.7/utim_cli/server/routes/__init__.py +9 -0
  15. utim_cli-1.44.7/utim_cli/server/routes/auth_routes.py +275 -0
  16. utim_cli-1.44.7/utim_cli/server/routes/completion_routes.py +568 -0
  17. utim_cli-1.44.7/utim_cli/server/routes/credit_routes.py +238 -0
  18. utim_cli-1.44.7/utim_cli/server/routes/quota_routes.py +1250 -0
  19. utim_cli-1.44.7/utim_cli/server/routes/session_routes.py +176 -0
  20. utim_cli-1.44.7/utim_cli/server/routes/share_routes.py +154 -0
  21. utim_cli-1.44.7/utim_cli/server/server.py +56 -0
  22. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/utim.py +3 -3
  23. {utim_cli-1.44.5 → utim_cli-1.44.7/utim_cli.egg-info}/PKG-INFO +1 -1
  24. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli.egg-info/SOURCES.txt +18 -0
  25. {utim_cli-1.44.5 → utim_cli-1.44.7}/CHANGELOG.md +0 -0
  26. {utim_cli-1.44.5 → utim_cli-1.44.7}/LICENSE +0 -0
  27. {utim_cli-1.44.5 → utim_cli-1.44.7}/README.md +0 -0
  28. {utim_cli-1.44.5 → utim_cli-1.44.7}/setup.cfg +0 -0
  29. {utim_cli-1.44.5 → utim_cli-1.44.7}/setup.py +0 -0
  30. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/__init__.py +0 -0
  31. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/agent.py +0 -0
  32. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/auth.py +0 -0
  33. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/backup.py +0 -0
  34. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/blender_agent.py +0 -0
  35. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/bootstrap.py +0 -0
  36. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/client_utils.py +0 -0
  37. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/config.py +0 -0
  38. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/constants.py +0 -0
  39. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/context_pruner.py +0 -0
  40. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/doctor.py +0 -0
  41. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/knowledge_graph.py +0 -0
  42. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/logger.py +0 -0
  43. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/mcp_clean_wrapper.py +0 -0
  44. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/mcp_client.py +0 -0
  45. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/mcp_registry.json +0 -0
  46. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/orchestrator.py +0 -0
  47. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/reflection.py +0 -0
  48. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/report.py +0 -0
  49. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/scrapy_search.py +0 -0
  50. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/share.py +0 -0
  51. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/share_tui.py +0 -0
  52. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/situational_scoring.py +0 -0
  53. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/state.py +0 -0
  54. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tools.py +0 -0
  55. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/__init__.py +0 -0
  56. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/history_dialog.py +0 -0
  57. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/mcp_dialog.py +0 -0
  58. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/model_dialog.py +0 -0
  59. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/resume_dialog.py +0 -0
  60. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/tools_dialog.py +0 -0
  61. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/tui/update_dialog.py +0 -0
  62. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/vector_memory.py +0 -0
  63. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli/workspace.py +0 -0
  64. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli.egg-info/dependency_links.txt +0 -0
  65. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli.egg-info/entry_points.txt +0 -0
  66. {utim_cli-1.44.5 → utim_cli-1.44.7}/utim_cli.egg-info/requires.txt +0 -0
  67. {utim_cli-1.44.5 → utim_cli-1.44.7}/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 (runs on Railway, not on user machines) ─────────
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utim-cli
3
- Version: 1.44.5
3
+ Version: 1.44.7
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "1.44.5"
7
+ version = "1.44.7"
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,4 @@
1
+ """UTIM Production Server package."""
2
+ from .router import app
3
+
4
+ __all__ = ["app"]
@@ -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()