msapling-cli 0.1.2__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.
@@ -0,0 +1,265 @@
1
+ """Local file storage helpers for MSapling CLI."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ _ROOT = Path.home() / ".msapling"
14
+
15
+
16
+ def _ensure(*parts: str) -> Path:
17
+ path = _ROOT.joinpath(*parts)
18
+ path.mkdir(parents=True, exist_ok=True)
19
+ return path
20
+
21
+
22
+ def _legacy_project_slug(project_root: str) -> str:
23
+ return re.sub(r"[^a-zA-Z0-9]", "-", str(project_root).strip('/\\'))
24
+
25
+
26
+ def _project_slug(project_root: str) -> str:
27
+ """Convert a project path to a collision-resistant slug."""
28
+ resolved_root = str(Path(project_root).resolve())
29
+ base = _legacy_project_slug(resolved_root).strip("-") or "project"
30
+ digest = hashlib.sha256(resolved_root.lower().encode("utf-8")).hexdigest()[:10]
31
+ return f"{base}-{digest}"
32
+
33
+
34
+ def _project_dirs(project_root: str) -> List[Path]:
35
+ primary = _ROOT / "projects" / _project_slug(project_root)
36
+ legacy = _ROOT / "projects" / _legacy_project_slug(project_root)
37
+ dirs = [primary]
38
+ if legacy != primary and legacy.exists():
39
+ dirs.append(legacy)
40
+ return dirs
41
+
42
+
43
+ def _utc_timestamp() -> str:
44
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
45
+
46
+
47
+ # Settings
48
+
49
+ def load_settings() -> Dict[str, Any]:
50
+ path = _ROOT / "settings.json"
51
+ if path.exists():
52
+ try:
53
+ return json.loads(path.read_text(encoding="utf-8"))
54
+ except Exception:
55
+ pass
56
+ return {
57
+ "permissions": {"allow": ["read_file", "glob_files", "grep_search", "web_search"]},
58
+ "model": "google/gemini-flash-1.5",
59
+ "effortLevel": "high",
60
+ }
61
+
62
+
63
+ def save_settings(settings: Dict[str, Any]):
64
+ _ensure()
65
+ path = _ROOT / "settings.json"
66
+ path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
67
+
68
+
69
+ # History
70
+
71
+ def append_history(prompt: str, project: str, session_id: str):
72
+ """Append a user prompt to global history."""
73
+ _ensure()
74
+ path = _ROOT / "history.jsonl"
75
+ entry = {
76
+ "display": prompt[:500],
77
+ "timestamp": int(time.time() * 1000),
78
+ "project": project,
79
+ "sessionId": session_id,
80
+ }
81
+ with open(path, "a", encoding="utf-8") as handle:
82
+ handle.write(json.dumps(entry) + "\n")
83
+
84
+
85
+ def load_history(limit: int = 50) -> List[Dict[str, Any]]:
86
+ path = _ROOT / "history.jsonl"
87
+ if not path.exists():
88
+ return []
89
+ entries = []
90
+ try:
91
+ for line in path.read_text(encoding="utf-8").strip().splitlines():
92
+ if line.strip():
93
+ entries.append(json.loads(line))
94
+ except Exception:
95
+ pass
96
+ return entries[-limit:]
97
+
98
+
99
+ # Sessions
100
+
101
+ def register_session(pid: int, session_id: str, cwd: str):
102
+ """Register an active session."""
103
+ _ensure("sessions")
104
+ path = _ROOT / "sessions" / f"{pid}.json"
105
+ path.write_text(json.dumps({
106
+ "pid": pid,
107
+ "sessionId": session_id,
108
+ "cwd": cwd,
109
+ "startedAt": int(time.time() * 1000),
110
+ "kind": "interactive",
111
+ "entrypoint": "cli",
112
+ }), encoding="utf-8")
113
+
114
+
115
+ def unregister_session(pid: int):
116
+ path = _ROOT / "sessions" / f"{pid}.json"
117
+ if path.exists():
118
+ path.unlink()
119
+
120
+
121
+ def list_active_sessions() -> List[Dict[str, Any]]:
122
+ sessions_dir = _ROOT / "sessions"
123
+ if not sessions_dir.exists():
124
+ return []
125
+ results = []
126
+ for session_file in sessions_dir.glob("*.json"):
127
+ try:
128
+ results.append(json.loads(session_file.read_text(encoding="utf-8")))
129
+ except Exception:
130
+ continue
131
+ return results
132
+
133
+
134
+ # Project conversations
135
+
136
+ def append_conversation(
137
+ project_root: str,
138
+ session_id: str,
139
+ msg_type: str,
140
+ content: str,
141
+ parent_uuid: Optional[str] = None,
142
+ uuid_val: Optional[str] = None,
143
+ ):
144
+ """Append a message to the conversation transcript."""
145
+ import uuid as _uuid
146
+
147
+ slug = _project_slug(project_root)
148
+ _ensure("projects", slug)
149
+ path = _ROOT / "projects" / slug / f"{session_id}.jsonl"
150
+ entry = {
151
+ "parentUuid": parent_uuid,
152
+ "type": msg_type,
153
+ "message": {"role": msg_type, "content": content[:10000]},
154
+ "uuid": uuid_val or str(_uuid.uuid4()),
155
+ "timestamp": _utc_timestamp(),
156
+ "sessionId": session_id,
157
+ "cwd": project_root,
158
+ }
159
+ with open(path, "a", encoding="utf-8") as handle:
160
+ handle.write(json.dumps(entry) + "\n")
161
+
162
+
163
+ def list_project_sessions(project_root: str) -> List[Dict[str, Any]]:
164
+ project_dirs = [path for path in _project_dirs(project_root) if path.exists()]
165
+ if not project_dirs:
166
+ return []
167
+
168
+ results = []
169
+ seen_session_ids = set()
170
+ files = []
171
+ for project_dir in project_dirs:
172
+ files.extend(project_dir.glob("*.jsonl"))
173
+
174
+ for transcript in sorted(files, key=lambda x: x.stat().st_mtime, reverse=True):
175
+ session_id = transcript.stem
176
+ if session_id in seen_session_ids:
177
+ continue
178
+ try:
179
+ lines = transcript.read_text(encoding="utf-8").strip().splitlines()
180
+ first_user = ""
181
+ for line in lines:
182
+ entry = json.loads(line)
183
+ if entry.get("type") == "user" and not entry.get("isMeta"):
184
+ first_user = entry.get("message", {}).get("content", "")[:80]
185
+ break
186
+ results.append({
187
+ "session_id": session_id,
188
+ "messages": len(lines),
189
+ "first_prompt": first_user,
190
+ "modified": transcript.stat().st_mtime,
191
+ })
192
+ seen_session_ids.add(session_id)
193
+ except Exception:
194
+ continue
195
+ return results
196
+
197
+
198
+ # Plans
199
+
200
+ def save_plan(name: str, content: str):
201
+ _ensure("plans")
202
+ path = _ROOT / "plans" / f"{name}.md"
203
+ path.write_text(content, encoding="utf-8")
204
+
205
+
206
+ def list_plans() -> List[str]:
207
+ plans_dir = _ROOT / "plans"
208
+ if not plans_dir.exists():
209
+ return []
210
+ return [plan.stem for plan in plans_dir.glob("*.md")]
211
+
212
+
213
+ def load_plan(name: str) -> Optional[str]:
214
+ path = _ROOT / "plans" / f"{name}.md"
215
+ if path.exists():
216
+ return path.read_text(encoding="utf-8")
217
+ return None
218
+
219
+
220
+ # File backups
221
+
222
+ def backup_file(file_path: str, content: str, session_id: str):
223
+ """Save a backup of a file before editing."""
224
+ _ensure("backups", session_id)
225
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", file_path)
226
+ backup_path = _ROOT / "backups" / session_id / f"{safe_name}.{int(time.time())}"
227
+ backup_path.write_text(content, encoding="utf-8")
228
+ return str(backup_path)
229
+
230
+
231
+ def list_backups(session_id: str) -> List[Dict[str, Any]]:
232
+ backup_dir = _ROOT / "backups" / session_id
233
+ if not backup_dir.exists():
234
+ return []
235
+ results = []
236
+ for backup in sorted(backup_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
237
+ results.append({
238
+ "name": backup.name,
239
+ "size": backup.stat().st_size,
240
+ "time": backup.stat().st_mtime,
241
+ })
242
+ return results
243
+
244
+
245
+ # Cache
246
+
247
+ def cache_set(key: str, data: Any, ttl: int = 3600):
248
+ _ensure("cache")
249
+ path = _ROOT / "cache" / f"{key}.json"
250
+ payload = {"data": data, "expires": time.time() + ttl}
251
+ path.write_text(json.dumps(payload), encoding="utf-8")
252
+
253
+
254
+ def cache_get(key: str) -> Optional[Any]:
255
+ path = _ROOT / "cache" / f"{key}.json"
256
+ if not path.exists():
257
+ return None
258
+ try:
259
+ payload = json.loads(path.read_text(encoding="utf-8"))
260
+ if payload.get("expires", 0) < time.time():
261
+ path.unlink()
262
+ return None
263
+ return payload.get("data")
264
+ except Exception:
265
+ return None
msapling_cli/tier.py ADDED
@@ -0,0 +1,78 @@
1
+ """Tier gating for MSapling CLI.
2
+
3
+ Free users get:
4
+ - Local commands (scan, context, git) - always free, no server
5
+ - chat with free models (flash, haiku, mini) - 10 msgs/day
6
+ - models, projects, whoami, config, ls, cat
7
+ - mcp-serve (basic tools only)
8
+
9
+ Pro users ($20/mo) get everything:
10
+ - All models (GPT-4, Claude 3.5, etc.)
11
+ - multi (parallel multi-model)
12
+ - swarm (parallel + judge synthesis)
13
+ - edit (LLM-driven file editing)
14
+ - benchmark (compare multiple models side-by-side)
15
+ - mcp-serve (all tools including swarm/multi)
16
+ - Unlimited messages
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Optional, Tuple
21
+
22
+ from rich.console import Console
23
+
24
+ console = Console()
25
+
26
+ FREE_MODELS = ("flash", "haiku", "mini", "gemma", "phi", "llama", "mistral", "qwen")
27
+ PRO_COMMANDS = {"multi", "swarm", "edit", "benchmark"}
28
+
29
+
30
+ def is_free_model(model_id: str) -> bool:
31
+ """Check if a model is available on free tier."""
32
+ mid = (model_id or "").lower()
33
+ if mid.startswith("ollama/"):
34
+ return True
35
+ return any(hint in mid for hint in FREE_MODELS)
36
+
37
+
38
+ def check_tier(user: dict, command: str, model: Optional[str] = None) -> Tuple[bool, str]:
39
+ """Check if user's tier allows this command.
40
+
41
+ Returns (allowed, message).
42
+ """
43
+ tier = str(user.get("tier", "free")).lower()
44
+ is_pro = tier in ("pro", "monthly", "lifetime", "enterprise") or user.get("is_pro", False)
45
+
46
+ if is_pro:
47
+ return True, ""
48
+
49
+ if command in PRO_COMMANDS:
50
+ return False, (
51
+ f"'{command}' requires a Pro subscription ($20/mo).\n"
52
+ f" Upgrade at: https://msapling.com/pricing\n"
53
+ f" Or use: msapling chat -i (free with basic models)"
54
+ )
55
+
56
+ if command == "chat" and model and not is_free_model(model):
57
+ return False, (
58
+ f"Model '{model}' requires Pro. Free tier models: Flash, Haiku, Mini, Llama, Mistral.\n"
59
+ " Try: msapling chat \"your prompt\" -m google/gemini-flash-1.5\n"
60
+ " Upgrade: https://msapling.com/pricing"
61
+ )
62
+
63
+ return True, ""
64
+
65
+
66
+ def require_pro(user: dict, command: str, model: Optional[str] = None) -> None:
67
+ """Check tier and exit with message if not allowed."""
68
+ allowed, msg = check_tier(user, command, model)
69
+ if not allowed:
70
+ console.print(f"[yellow]{msg}[/yellow]")
71
+ raise SystemExit(0)
72
+
73
+
74
+ def require_auth(token: Optional[str]) -> None:
75
+ """Check that user is authenticated."""
76
+ if not token:
77
+ console.print("[red]Not logged in. Run: msapling login[/red]")
78
+ raise SystemExit(1)