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.
- msapling_cli/__init__.py +2 -0
- msapling_cli/agent.py +671 -0
- msapling_cli/api.py +394 -0
- msapling_cli/completer.py +415 -0
- msapling_cli/config.py +56 -0
- msapling_cli/local.py +133 -0
- msapling_cli/main.py +1038 -0
- msapling_cli/mcp/__init__.py +1 -0
- msapling_cli/mcp/server.py +411 -0
- msapling_cli/memory.py +97 -0
- msapling_cli/session.py +102 -0
- msapling_cli/shell.py +1583 -0
- msapling_cli/storage.py +265 -0
- msapling_cli/tier.py +78 -0
- msapling_cli/tui.py +475 -0
- msapling_cli/worker_pool.py +233 -0
- msapling_cli-0.1.2.dist-info/METADATA +132 -0
- msapling_cli-0.1.2.dist-info/RECORD +22 -0
- msapling_cli-0.1.2.dist-info/WHEEL +5 -0
- msapling_cli-0.1.2.dist-info/entry_points.txt +3 -0
- msapling_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- msapling_cli-0.1.2.dist-info/top_level.txt +1 -0
msapling_cli/storage.py
ADDED
|
@@ -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)
|