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/share.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import datetime
|
|
5
|
+
import zipfile
|
|
6
|
+
import requests
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Dict, Tuple, Optional
|
|
9
|
+
|
|
10
|
+
# Constants
|
|
11
|
+
SHARES_DIR = Path(".utim/shares")
|
|
12
|
+
SHARES_META_FILE = Path(".utim/shares.json")
|
|
13
|
+
|
|
14
|
+
# Default exclude categories and their descriptions
|
|
15
|
+
EXCLUDE_OPTIONS = [
|
|
16
|
+
{"key": "node_modules", "name": "node_modules", "desc": "Node.js dependencies"},
|
|
17
|
+
{"key": ".git", "name": ".git", "desc": "Git repository history"},
|
|
18
|
+
{"key": "__pycache__", "name": "__pycache__", "desc": "Python bytecode cache"},
|
|
19
|
+
{"key": "venv", "name": "venv / .venv", "desc": "Python virtual environments"},
|
|
20
|
+
{"key": "dist", "name": "dist / build", "desc": "Compiled build/distribution folders"},
|
|
21
|
+
{"key": ".next", "name": ".next / .nuxt", "desc": "Next.js / Nuxt.js dev & build caches"},
|
|
22
|
+
{"key": "target", "name": "target", "desc": "Rust cargo build artifacts"},
|
|
23
|
+
{"key": ".env", "name": ".env / configuration", "desc": "Environment secrets and local configs"},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Expiry options in hours
|
|
27
|
+
EXPIRY_OPTIONS = [
|
|
28
|
+
{"label": "15 Minutes", "hours": 0.25},
|
|
29
|
+
{"label": "1 Hour", "hours": 1.0},
|
|
30
|
+
{"label": "4 Hours", "hours": 4.0},
|
|
31
|
+
{"label": "24 Hours", "hours": 24.0},
|
|
32
|
+
{"label": "3 Days", "hours": 72.0},
|
|
33
|
+
{"label": "7 Days", "hours": 168.0},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
class ShareRecord:
|
|
37
|
+
def __init__(self, id: str, name: str, created_at: str, expires_at: str, link: str, file_path: str, excluded: List[str]):
|
|
38
|
+
self.id = id
|
|
39
|
+
self.name = name
|
|
40
|
+
self.created_at = created_at
|
|
41
|
+
self.expires_at = expires_at
|
|
42
|
+
self.link = link
|
|
43
|
+
self.file_path = file_path
|
|
44
|
+
self.excluded = excluded
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> Dict:
|
|
47
|
+
return {
|
|
48
|
+
"id": self.id,
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"created_at": self.created_at,
|
|
51
|
+
"expires_at": self.expires_at,
|
|
52
|
+
"link": self.link,
|
|
53
|
+
"file_path": self.file_path,
|
|
54
|
+
"excluded": self.excluded
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: Dict):
|
|
59
|
+
return cls(
|
|
60
|
+
id=data["id"],
|
|
61
|
+
name=data["name"],
|
|
62
|
+
created_at=data["created_at"],
|
|
63
|
+
expires_at=data["expires_at"],
|
|
64
|
+
link=data["link"],
|
|
65
|
+
file_path=data["file_path"],
|
|
66
|
+
excluded=data.get("excluded", [])
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def is_expired(self) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
exp = datetime.datetime.fromisoformat(self.expires_at)
|
|
72
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
73
|
+
return now > exp
|
|
74
|
+
except Exception:
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def time_remaining(self) -> str:
|
|
78
|
+
if self.is_expired():
|
|
79
|
+
return "Expired"
|
|
80
|
+
try:
|
|
81
|
+
exp = datetime.datetime.fromisoformat(self.expires_at)
|
|
82
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
83
|
+
delta = exp - now
|
|
84
|
+
|
|
85
|
+
secs = int(delta.total_seconds())
|
|
86
|
+
if secs < 60:
|
|
87
|
+
return f"{secs}s left"
|
|
88
|
+
mins = secs // 60
|
|
89
|
+
if mins < 60:
|
|
90
|
+
return f"{mins}m left"
|
|
91
|
+
hours = mins // 60
|
|
92
|
+
if hours < 24:
|
|
93
|
+
return f"{hours}h {mins % 60}m left"
|
|
94
|
+
days = hours // 24
|
|
95
|
+
return f"{days}d {hours % 24}h left"
|
|
96
|
+
except Exception:
|
|
97
|
+
return "Unknown"
|
|
98
|
+
|
|
99
|
+
class ShareManager:
|
|
100
|
+
def __init__(self, workspace_path: str = "."):
|
|
101
|
+
self.workspace_path = Path(workspace_path).resolve()
|
|
102
|
+
self.shares_dir = self.workspace_path / SHARES_DIR
|
|
103
|
+
self.meta_file = self.workspace_path / SHARES_META_FILE
|
|
104
|
+
self.shares_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
self._load_meta()
|
|
106
|
+
|
|
107
|
+
def _load_meta(self):
|
|
108
|
+
self.records: Dict[str, ShareRecord] = {}
|
|
109
|
+
if self.meta_file.exists():
|
|
110
|
+
try:
|
|
111
|
+
with open(self.meta_file, 'r', encoding='utf-8') as f:
|
|
112
|
+
data = json.load(f)
|
|
113
|
+
for item in data:
|
|
114
|
+
rec = ShareRecord.from_dict(item)
|
|
115
|
+
self.records[rec.id] = rec
|
|
116
|
+
except Exception:
|
|
117
|
+
# Fallback to empty if corrupted
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def _save_meta(self):
|
|
121
|
+
try:
|
|
122
|
+
data = [rec.to_dict() for rec in self.records.values()]
|
|
123
|
+
with open(self.meta_file, 'w', encoding='utf-8') as f:
|
|
124
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def get_all(self) -> List[ShareRecord]:
|
|
129
|
+
# Return sorted by created_at descending
|
|
130
|
+
return sorted(self.records.values(), key=lambda r: r.created_at, reverse=True)
|
|
131
|
+
|
|
132
|
+
def search(self, query: str) -> List[ShareRecord]:
|
|
133
|
+
q = query.lower().strip()
|
|
134
|
+
all_records = self.get_all()
|
|
135
|
+
if not q:
|
|
136
|
+
return all_records
|
|
137
|
+
|
|
138
|
+
filtered = []
|
|
139
|
+
for r in all_records:
|
|
140
|
+
if q in r.name.lower() or q in r.id.lower() or q in r.link.lower() or any(q in excl.lower() for excl in r.excluded):
|
|
141
|
+
filtered.append(r)
|
|
142
|
+
return filtered
|
|
143
|
+
|
|
144
|
+
def delete(self, share_id: str) -> bool:
|
|
145
|
+
if share_id in self.records:
|
|
146
|
+
rec = self.records[share_id]
|
|
147
|
+
# Try to delete the zip file
|
|
148
|
+
zip_p = Path(rec.file_path)
|
|
149
|
+
if zip_p.exists():
|
|
150
|
+
try:
|
|
151
|
+
zip_p.unlink()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# Remove from meta
|
|
156
|
+
del self.records[share_id]
|
|
157
|
+
self._save_meta()
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def create_share(self, exclude_keys: List[str], expiry_hours: float, chat_messages: List[Dict]) -> ShareRecord:
|
|
162
|
+
share_id = f"share_{uuid.uuid4().hex[:12]}"
|
|
163
|
+
created_at = datetime.datetime.now(datetime.timezone.utc)
|
|
164
|
+
expires_at = created_at + datetime.timedelta(hours=expiry_hours)
|
|
165
|
+
|
|
166
|
+
# Name of workspace
|
|
167
|
+
workspace_name = self.workspace_path.name or "workspace"
|
|
168
|
+
zip_filename = f"{workspace_name}_{share_id}.zip"
|
|
169
|
+
zip_filepath = self.shares_dir / zip_filename
|
|
170
|
+
|
|
171
|
+
# Exclude folders
|
|
172
|
+
# Resolve folders/names to exclude
|
|
173
|
+
exclude_names = []
|
|
174
|
+
for key in exclude_keys:
|
|
175
|
+
# Map categories to actual folder names to filter
|
|
176
|
+
if key == "venv":
|
|
177
|
+
exclude_names.extend(["venv", ".venv", "env", ".env-venv"])
|
|
178
|
+
elif key == "dist":
|
|
179
|
+
exclude_names.extend(["dist", "build", "out", "target-distribution"])
|
|
180
|
+
elif key == ".next":
|
|
181
|
+
exclude_names.extend([".next", ".nuxt", ".expo"])
|
|
182
|
+
elif key == ".env":
|
|
183
|
+
exclude_names.extend([".env", ".env.local", ".env.development", ".env.production"])
|
|
184
|
+
else:
|
|
185
|
+
exclude_names.append(key)
|
|
186
|
+
|
|
187
|
+
# Generate markdown chat history
|
|
188
|
+
chat_history_content = self._format_chat_history(chat_messages)
|
|
189
|
+
|
|
190
|
+
# Zip files
|
|
191
|
+
self._zip_workspace(zip_filepath, exclude_names, chat_history_content)
|
|
192
|
+
|
|
193
|
+
# Upload and get link
|
|
194
|
+
link = self._upload_file(zip_filepath, expiry_hours)
|
|
195
|
+
|
|
196
|
+
rec = ShareRecord(
|
|
197
|
+
id=share_id,
|
|
198
|
+
name=workspace_name,
|
|
199
|
+
created_at=created_at.isoformat(),
|
|
200
|
+
expires_at=expires_at.isoformat(),
|
|
201
|
+
link=link,
|
|
202
|
+
file_path=str(zip_filepath),
|
|
203
|
+
excluded=exclude_keys
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self.records[share_id] = rec
|
|
207
|
+
self._save_meta()
|
|
208
|
+
return rec
|
|
209
|
+
|
|
210
|
+
def _format_chat_history(self, messages: List[Dict]) -> str:
|
|
211
|
+
md = []
|
|
212
|
+
md.append("# UTIM CLI Chat History\n")
|
|
213
|
+
md.append(f"Shared Workspace: `{self.workspace_path.name}`\n")
|
|
214
|
+
md.append(f"Export Date: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
215
|
+
md.append("---\n\n")
|
|
216
|
+
|
|
217
|
+
for msg in messages:
|
|
218
|
+
role = msg.get("role", "").capitalize()
|
|
219
|
+
content = msg.get("content", "")
|
|
220
|
+
|
|
221
|
+
# Safely extract text if content is a list
|
|
222
|
+
if isinstance(content, list):
|
|
223
|
+
text = "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and "text" in p)
|
|
224
|
+
else:
|
|
225
|
+
text = str(content)
|
|
226
|
+
|
|
227
|
+
if role == "System":
|
|
228
|
+
md.append(f"### ⚙️ System Prompt\n\n```\n{text}\n```\n\n")
|
|
229
|
+
elif role == "User":
|
|
230
|
+
md.append(f"### 👤 User\n\n{text}\n\n")
|
|
231
|
+
elif role == "Assistant":
|
|
232
|
+
md.append(f"### 🤖 Assistant\n\n{text}\n\n")
|
|
233
|
+
elif role == "Tool":
|
|
234
|
+
name = msg.get("name", "Tool")
|
|
235
|
+
md.append(f"### 🛠️ Tool: {name}\n\n```\n{text}\n```\n\n")
|
|
236
|
+
else:
|
|
237
|
+
md.append(f"### 📝 {role}\n\n{text}\n\n")
|
|
238
|
+
|
|
239
|
+
md.append("---\n\n")
|
|
240
|
+
|
|
241
|
+
return "".join(md)
|
|
242
|
+
|
|
243
|
+
def _zip_workspace(self, output_zip_path: Path, exclude_names: List[str], chat_history: str):
|
|
244
|
+
with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
245
|
+
# 1. Add the chat history first at root as chat_history.md
|
|
246
|
+
zipf.writestr("chat_history.md", chat_history)
|
|
247
|
+
|
|
248
|
+
# 2. Walk directory recursively
|
|
249
|
+
for root, dirs, files in os.walk(self.workspace_path):
|
|
250
|
+
# Filter directories in-place to prevent os.walk recursion
|
|
251
|
+
# Skip .utim directory so we don't zip previous zips
|
|
252
|
+
dirs[:] = [d for d in dirs if d not in exclude_names and d != ".utim"]
|
|
253
|
+
|
|
254
|
+
for file in files:
|
|
255
|
+
if file in exclude_names or file.endswith(('.pyc', '.pyo', '.pyd')):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Also skip if file path directory component is excluded
|
|
259
|
+
path_parts = Path(root).relative_to(self.workspace_path).parts
|
|
260
|
+
if any(p in exclude_names or p == ".utim" for p in path_parts):
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
full_path = Path(root) / file
|
|
264
|
+
# Skip the zip itself
|
|
265
|
+
if full_path.resolve() == output_zip_path.resolve():
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Safe relative path inside the zip
|
|
269
|
+
rel_path = full_path.relative_to(self.workspace_path)
|
|
270
|
+
try:
|
|
271
|
+
zipf.write(str(full_path), str(rel_path))
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
def _upload_file(self, zip_filepath: Path, expiry_hours: float) -> str:
|
|
276
|
+
# Determine expiry string for file.io
|
|
277
|
+
if expiry_hours <= 0.25:
|
|
278
|
+
exp = "15m"
|
|
279
|
+
elif expiry_hours <= 1.0:
|
|
280
|
+
exp = "1h"
|
|
281
|
+
elif expiry_hours <= 4.0:
|
|
282
|
+
exp = "4h"
|
|
283
|
+
elif expiry_hours <= 24.0:
|
|
284
|
+
exp = "1d"
|
|
285
|
+
elif expiry_hours <= 72.0:
|
|
286
|
+
exp = "3d"
|
|
287
|
+
else:
|
|
288
|
+
exp = "7d"
|
|
289
|
+
|
|
290
|
+
# Check UTIM_SERVER_URL env var
|
|
291
|
+
server_url = os.getenv("UTIM_SERVER_URL", "")
|
|
292
|
+
if server_url:
|
|
293
|
+
try:
|
|
294
|
+
# Try uploading to configured server endpoint
|
|
295
|
+
url = f"{server_url}/shares/upload"
|
|
296
|
+
with open(zip_filepath, 'rb') as f:
|
|
297
|
+
response = requests.post(url, files={"file": f}, data={"expires": exp}, timeout=30)
|
|
298
|
+
if response.status_code == 200:
|
|
299
|
+
data = response.json()
|
|
300
|
+
if data.get("link"):
|
|
301
|
+
return data["link"]
|
|
302
|
+
except Exception:
|
|
303
|
+
# Fallback to public file.io if server upload fails
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
# Fallback public upload using file.io
|
|
307
|
+
try:
|
|
308
|
+
url = f"https://file.io/?expires={exp}"
|
|
309
|
+
with open(zip_filepath, 'rb') as f:
|
|
310
|
+
response = requests.post(url, files={"file": f}, timeout=30)
|
|
311
|
+
if response.status_code == 200:
|
|
312
|
+
data = response.json()
|
|
313
|
+
if data.get("success") and data.get("link"):
|
|
314
|
+
return data["link"]
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Fallback to local link if offline or upload fails
|
|
319
|
+
# Return a custom local URI
|
|
320
|
+
return f"file:///{zip_filepath.resolve().as_posix()}"
|