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/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()}"