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
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Tab completion for MSapling interactive shell.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- Slash command completion: typing "/" then Tab shows matching commands
|
|
5
|
+
- File path completion: after /read, /edit, /write, /mention, /image, Tab completes
|
|
6
|
+
file paths relative to the project root
|
|
7
|
+
- Model completion: after /model, Tab shows available model IDs
|
|
8
|
+
|
|
9
|
+
Uses Python's readline module on Linux/Mac. On Windows, attempts to use
|
|
10
|
+
pyreadline3; if unavailable, completion is silently disabled.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import glob as globmod
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# All known slash commands (extracted from shell.py SLASH_HELP + handlers)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
SLASH_COMMANDS: List[str] = [
|
|
23
|
+
"/help",
|
|
24
|
+
"/clear",
|
|
25
|
+
"/compact",
|
|
26
|
+
"/context",
|
|
27
|
+
"/save",
|
|
28
|
+
"/resume",
|
|
29
|
+
"/sessions",
|
|
30
|
+
"/fork",
|
|
31
|
+
"/rewind",
|
|
32
|
+
"/quit",
|
|
33
|
+
"/exit",
|
|
34
|
+
"/q",
|
|
35
|
+
"/model",
|
|
36
|
+
"/models",
|
|
37
|
+
"/plan",
|
|
38
|
+
"/agent",
|
|
39
|
+
"/simple",
|
|
40
|
+
"/vim",
|
|
41
|
+
"/fast",
|
|
42
|
+
"/effort",
|
|
43
|
+
"/files",
|
|
44
|
+
"/read",
|
|
45
|
+
"/write",
|
|
46
|
+
"/edit",
|
|
47
|
+
"/grep",
|
|
48
|
+
"/mention",
|
|
49
|
+
"/attach",
|
|
50
|
+
"/image",
|
|
51
|
+
"/run",
|
|
52
|
+
"/git",
|
|
53
|
+
"/diff",
|
|
54
|
+
"/status",
|
|
55
|
+
"/multi",
|
|
56
|
+
"/swarm",
|
|
57
|
+
"/project",
|
|
58
|
+
"/init",
|
|
59
|
+
"/whoami",
|
|
60
|
+
"/cost",
|
|
61
|
+
"/hooks",
|
|
62
|
+
"/memory",
|
|
63
|
+
"/mdrive",
|
|
64
|
+
"/workers",
|
|
65
|
+
"/join",
|
|
66
|
+
"/broadcast",
|
|
67
|
+
"/collect",
|
|
68
|
+
"/kill",
|
|
69
|
+
"/budget",
|
|
70
|
+
"/copy",
|
|
71
|
+
"/export",
|
|
72
|
+
"/restore",
|
|
73
|
+
"/permissions",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# Commands after which Tab should complete file paths
|
|
77
|
+
FILE_PATH_COMMANDS = {"/read", "/edit", "/write", "/mention", "/attach", "/image"}
|
|
78
|
+
|
|
79
|
+
# Directories to skip during file path completion
|
|
80
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build", ".mypy_cache", ".pytest_cache"}
|
|
81
|
+
|
|
82
|
+
# Well-known model IDs for offline completion (supplemented at runtime)
|
|
83
|
+
DEFAULT_MODELS: List[str] = [
|
|
84
|
+
"google/gemini-flash-1.5",
|
|
85
|
+
"google/gemini-pro-1.5",
|
|
86
|
+
"google/gemini-2.0-flash",
|
|
87
|
+
"anthropic/claude-3-haiku",
|
|
88
|
+
"anthropic/claude-3-sonnet",
|
|
89
|
+
"anthropic/claude-3-opus",
|
|
90
|
+
"anthropic/claude-3.5-sonnet",
|
|
91
|
+
"openai/gpt-4o",
|
|
92
|
+
"openai/gpt-4o-mini",
|
|
93
|
+
"openai/gpt-4-turbo",
|
|
94
|
+
"openai/o1-mini",
|
|
95
|
+
"openai/o1-preview",
|
|
96
|
+
"meta-llama/llama-3-70b-instruct",
|
|
97
|
+
"meta-llama/llama-3-8b-instruct",
|
|
98
|
+
"mistralai/mistral-large",
|
|
99
|
+
"mistralai/mistral-small",
|
|
100
|
+
"qwen/qwen-2-72b-instruct",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MSaplingCompleter:
|
|
105
|
+
"""Readline-compatible completer for the MSapling interactive shell."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
commands: Optional[Sequence[str]] = None,
|
|
110
|
+
project_root: str = ".",
|
|
111
|
+
models: Optional[Sequence[str]] = None,
|
|
112
|
+
):
|
|
113
|
+
self.commands = sorted(commands or SLASH_COMMANDS)
|
|
114
|
+
self.project_root = os.path.abspath(project_root)
|
|
115
|
+
self.models = sorted(set(models or DEFAULT_MODELS))
|
|
116
|
+
self._matches: List[str] = []
|
|
117
|
+
|
|
118
|
+
def set_models(self, models: Sequence[str]) -> None:
|
|
119
|
+
"""Update the model list (e.g. after fetching from the server)."""
|
|
120
|
+
self.models = sorted(set(list(models) + DEFAULT_MODELS))
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Core readline interface
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def complete(self, text: str, state: int) -> Optional[str]:
|
|
127
|
+
"""Called by readline for each successive completion.
|
|
128
|
+
|
|
129
|
+
*text* is the current word being completed (the portion after the
|
|
130
|
+
last whitespace). *state* counts up from 0; return None to signal
|
|
131
|
+
end of matches.
|
|
132
|
+
"""
|
|
133
|
+
if state == 0:
|
|
134
|
+
# Compute matches fresh for state == 0
|
|
135
|
+
try:
|
|
136
|
+
line = _get_line_buffer()
|
|
137
|
+
except Exception:
|
|
138
|
+
line = text
|
|
139
|
+
# If line buffer is empty (readline not fully wired), use text
|
|
140
|
+
if not line:
|
|
141
|
+
line = text
|
|
142
|
+
|
|
143
|
+
self._matches = self._compute_matches(text, line)
|
|
144
|
+
|
|
145
|
+
if state < len(self._matches):
|
|
146
|
+
return self._matches[state]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Match computation
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _compute_matches(self, text: str, full_line: str) -> List[str]:
|
|
154
|
+
"""Decide which completions to offer based on context."""
|
|
155
|
+
stripped = full_line.lstrip()
|
|
156
|
+
|
|
157
|
+
# ── Case 1: completing a slash command name ──────────────────
|
|
158
|
+
if stripped.startswith("/") and " " not in stripped:
|
|
159
|
+
return [cmd + " " for cmd in self.commands if cmd.startswith(stripped)]
|
|
160
|
+
|
|
161
|
+
# ── Case 2: completing arguments to a command ────────────────
|
|
162
|
+
parts = stripped.split(None, 1)
|
|
163
|
+
if len(parts) >= 1:
|
|
164
|
+
cmd = parts[0].lower()
|
|
165
|
+
|
|
166
|
+
# File path completion
|
|
167
|
+
if cmd in FILE_PATH_COMMANDS:
|
|
168
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
169
|
+
return self._complete_path(partial)
|
|
170
|
+
|
|
171
|
+
# Model completion
|
|
172
|
+
if cmd == "/model":
|
|
173
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
174
|
+
return [m + " " for m in self.models if m.startswith(partial)]
|
|
175
|
+
|
|
176
|
+
# /effort level completion
|
|
177
|
+
if cmd == "/effort":
|
|
178
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
179
|
+
levels = ["low", "medium", "high"]
|
|
180
|
+
return [lv + " " for lv in levels if lv.startswith(partial)]
|
|
181
|
+
|
|
182
|
+
# /hooks subcommand completion
|
|
183
|
+
if cmd == "/hooks":
|
|
184
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
185
|
+
hook_subs = ["add", "remove", "clear"]
|
|
186
|
+
return [s + " " for s in hook_subs if s.startswith(partial)]
|
|
187
|
+
|
|
188
|
+
# /mdrive subcommand completion
|
|
189
|
+
if cmd == "/mdrive":
|
|
190
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
191
|
+
mdrive_subs = ["status", "ls", "push", "pull"]
|
|
192
|
+
return [s + " " for s in mdrive_subs if s.startswith(partial)]
|
|
193
|
+
|
|
194
|
+
# /memory subcommand completion
|
|
195
|
+
if cmd == "/memory":
|
|
196
|
+
partial = parts[1] if len(parts) > 1 else ""
|
|
197
|
+
mem_subs = ["add", "clear"]
|
|
198
|
+
return [s + " " for s in mem_subs if s.startswith(partial)]
|
|
199
|
+
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# File path completion
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _complete_path(self, partial: str) -> List[str]:
|
|
207
|
+
"""Complete a file path relative to the project root.
|
|
208
|
+
|
|
209
|
+
Returns paths with a trailing '/' for directories so the user can
|
|
210
|
+
keep drilling down without pressing Tab again.
|
|
211
|
+
"""
|
|
212
|
+
# Normalise to forward slashes for cross-platform consistency
|
|
213
|
+
partial = partial.replace("\\", "/")
|
|
214
|
+
|
|
215
|
+
# Build the absolute prefix to glob against
|
|
216
|
+
abs_prefix = os.path.join(self.project_root, partial)
|
|
217
|
+
|
|
218
|
+
# Glob for matches
|
|
219
|
+
try:
|
|
220
|
+
raw = globmod.glob(abs_prefix + "*")
|
|
221
|
+
except Exception:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
matches: List[str] = []
|
|
225
|
+
for full_path in sorted(raw)[:60]:
|
|
226
|
+
rel = os.path.relpath(full_path, self.project_root).replace("\\", "/")
|
|
227
|
+
|
|
228
|
+
# Skip hidden / noisy directories
|
|
229
|
+
parts = rel.split("/")
|
|
230
|
+
if any(p in _SKIP_DIRS for p in parts):
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if os.path.isdir(full_path):
|
|
234
|
+
matches.append(rel + "/")
|
|
235
|
+
else:
|
|
236
|
+
matches.append(rel + " ")
|
|
237
|
+
|
|
238
|
+
return matches
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ======================================================================
|
|
242
|
+
# Module-level setup function (called from shell.py)
|
|
243
|
+
# ======================================================================
|
|
244
|
+
|
|
245
|
+
_rl = None # Will hold the readline module if available
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _get_line_buffer() -> str:
|
|
249
|
+
"""Return the current readline line buffer, or empty string."""
|
|
250
|
+
if _rl is not None:
|
|
251
|
+
try:
|
|
252
|
+
return _rl.get_line_buffer()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return ""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def setup_completion(
|
|
259
|
+
project_root: str = ".",
|
|
260
|
+
commands: Optional[Sequence[str]] = None,
|
|
261
|
+
models: Optional[Sequence[str]] = None,
|
|
262
|
+
) -> Optional[MSaplingCompleter]:
|
|
263
|
+
"""Configure readline tab completion for the interactive shell.
|
|
264
|
+
|
|
265
|
+
Returns the MSaplingCompleter instance (so the caller can later call
|
|
266
|
+
``completer.set_models(...)``), or ``None`` if readline is not available.
|
|
267
|
+
|
|
268
|
+
Safe to call on any platform -- silently does nothing on Windows if
|
|
269
|
+
neither readline nor pyreadline3 is installed.
|
|
270
|
+
"""
|
|
271
|
+
global _rl
|
|
272
|
+
|
|
273
|
+
# Try to import readline (works on Linux/Mac; may exist on Windows with pyreadline3)
|
|
274
|
+
try:
|
|
275
|
+
import readline as rl
|
|
276
|
+
_rl = rl
|
|
277
|
+
except ImportError:
|
|
278
|
+
try:
|
|
279
|
+
# pyreadline3 is a Windows drop-in replacement
|
|
280
|
+
import pyreadline3 as rl # type: ignore[import-untyped]
|
|
281
|
+
_rl = rl
|
|
282
|
+
except ImportError:
|
|
283
|
+
# No readline available -- tab completion disabled silently
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
completer = MSaplingCompleter(
|
|
287
|
+
commands=commands,
|
|
288
|
+
project_root=project_root,
|
|
289
|
+
models=models,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
_rl.set_completer(completer.complete)
|
|
293
|
+
|
|
294
|
+
# Set the word delimiters. We want "/" to NOT be a delimiter so that
|
|
295
|
+
# "/mod" is treated as one word (not split at "/").
|
|
296
|
+
_rl.set_completer_delims(" \t\n;")
|
|
297
|
+
|
|
298
|
+
# Enable tab completion binding.
|
|
299
|
+
# On Mac with libedit, the syntax is different.
|
|
300
|
+
try:
|
|
301
|
+
if "libedit" in (_rl.__doc__ or ""):
|
|
302
|
+
_rl.parse_and_bind("bind ^I rl_complete")
|
|
303
|
+
else:
|
|
304
|
+
_rl.parse_and_bind("tab: complete")
|
|
305
|
+
except Exception:
|
|
306
|
+
# Last resort -- try both and ignore errors
|
|
307
|
+
try:
|
|
308
|
+
_rl.parse_and_bind("tab: complete")
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
return completer
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ======================================================================
|
|
316
|
+
# Fuzzy model resolver
|
|
317
|
+
# ======================================================================
|
|
318
|
+
|
|
319
|
+
# Common model aliases — user-friendly names → OpenRouter IDs
|
|
320
|
+
MODEL_ALIASES: dict[str, str] = {
|
|
321
|
+
# Gemini
|
|
322
|
+
"gemini flash": "google/gemini-2.0-flash-001",
|
|
323
|
+
"gemini 2.0 flash": "google/gemini-2.0-flash-001",
|
|
324
|
+
"gemini 2 flash": "google/gemini-2.0-flash-001",
|
|
325
|
+
"gemini flash 1.5": "google/gemini-flash-1.5",
|
|
326
|
+
"gemini 1.5 flash": "google/gemini-flash-1.5",
|
|
327
|
+
"gemini pro": "google/gemini-2.0-pro-exp-02-05",
|
|
328
|
+
"gemini 2.0 pro": "google/gemini-2.0-pro-exp-02-05",
|
|
329
|
+
"gemini 1.5 pro": "google/gemini-pro-1.5",
|
|
330
|
+
# Claude
|
|
331
|
+
"claude": "anthropic/claude-3.5-sonnet",
|
|
332
|
+
"claude sonnet": "anthropic/claude-3.5-sonnet",
|
|
333
|
+
"claude 3.5 sonnet": "anthropic/claude-3.5-sonnet",
|
|
334
|
+
"claude haiku": "anthropic/claude-3-haiku",
|
|
335
|
+
"claude 3 haiku": "anthropic/claude-3-haiku",
|
|
336
|
+
"claude opus": "anthropic/claude-3-opus",
|
|
337
|
+
"claude 3 opus": "anthropic/claude-3-opus",
|
|
338
|
+
# GPT
|
|
339
|
+
"gpt4": "openai/gpt-4o",
|
|
340
|
+
"gpt 4": "openai/gpt-4o",
|
|
341
|
+
"gpt4o": "openai/gpt-4o",
|
|
342
|
+
"gpt 4o": "openai/gpt-4o",
|
|
343
|
+
"gpt4 mini": "openai/gpt-4o-mini",
|
|
344
|
+
"gpt 4o mini": "openai/gpt-4o-mini",
|
|
345
|
+
"gpt mini": "openai/gpt-4o-mini",
|
|
346
|
+
"o1": "openai/o1",
|
|
347
|
+
"o1 mini": "openai/o1-mini",
|
|
348
|
+
"o3 mini": "openai/o3-mini",
|
|
349
|
+
# Llama
|
|
350
|
+
"llama": "meta-llama/llama-3.1-70b-instruct",
|
|
351
|
+
"llama 3": "meta-llama/llama-3.1-70b-instruct",
|
|
352
|
+
"llama 3.1": "meta-llama/llama-3.1-70b-instruct",
|
|
353
|
+
"llama 70b": "meta-llama/llama-3.1-70b-instruct",
|
|
354
|
+
"llama 8b": "meta-llama/llama-3.1-8b-instruct",
|
|
355
|
+
# Mistral
|
|
356
|
+
"mistral": "mistralai/mistral-large",
|
|
357
|
+
"mistral large": "mistralai/mistral-large",
|
|
358
|
+
"mistral small": "mistralai/mistral-small",
|
|
359
|
+
# DeepSeek
|
|
360
|
+
"deepseek": "deepseek/deepseek-chat",
|
|
361
|
+
"deepseek coder": "deepseek/deepseek-coder",
|
|
362
|
+
# Qwen
|
|
363
|
+
"qwen": "qwen/qwen-2.5-72b-instruct",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def resolve_model(user_input: str, available_models: Optional[List[str]] = None) -> tuple[str, bool]:
|
|
368
|
+
"""Resolve a fuzzy model name to an exact OpenRouter model ID.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
user_input: What the user typed (e.g. "gemini 2.0 flash", "gpt4", "claude")
|
|
372
|
+
available_models: Optional list of available model IDs from the server
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
(model_id, was_fuzzy) — the resolved ID and whether fuzzy matching was used.
|
|
376
|
+
"""
|
|
377
|
+
raw = user_input.strip()
|
|
378
|
+
if not raw:
|
|
379
|
+
return "google/gemini-flash-1.5", True
|
|
380
|
+
|
|
381
|
+
# 1. Exact match (user typed a full ID like "google/gemini-2.0-flash-001")
|
|
382
|
+
if "/" in raw:
|
|
383
|
+
return raw, False
|
|
384
|
+
|
|
385
|
+
# 2. Alias match (case-insensitive)
|
|
386
|
+
lower = raw.lower()
|
|
387
|
+
if lower in MODEL_ALIASES:
|
|
388
|
+
return MODEL_ALIASES[lower], True
|
|
389
|
+
|
|
390
|
+
# 3. Partial alias match — find best match by checking if all user words appear in alias
|
|
391
|
+
user_words = lower.split()
|
|
392
|
+
best_alias = None
|
|
393
|
+
best_score = 0
|
|
394
|
+
for alias, model_id in MODEL_ALIASES.items():
|
|
395
|
+
alias_words = alias.split()
|
|
396
|
+
matches = sum(1 for w in user_words if any(w in aw for aw in alias_words))
|
|
397
|
+
if matches > best_score:
|
|
398
|
+
best_score = matches
|
|
399
|
+
best_alias = model_id
|
|
400
|
+
if best_alias and best_score >= len(user_words) * 0.5:
|
|
401
|
+
return best_alias, True
|
|
402
|
+
|
|
403
|
+
# 4. Search available models by substring
|
|
404
|
+
if available_models:
|
|
405
|
+
for mid in available_models:
|
|
406
|
+
if lower in mid.lower():
|
|
407
|
+
return mid, True
|
|
408
|
+
|
|
409
|
+
# 5. Search default models
|
|
410
|
+
for mid in DEFAULT_MODELS:
|
|
411
|
+
if lower.replace(" ", "").replace(".", "") in mid.lower().replace("-", "").replace(".", ""):
|
|
412
|
+
return mid, True
|
|
413
|
+
|
|
414
|
+
# 6. Give up — return as-is, let the API decide
|
|
415
|
+
return raw, False
|
msapling_cli/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration and authentication for MSapling CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from pydantic_settings import BaseSettings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_CONFIG_DIR = Path.home() / ".msapling"
|
|
13
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
14
|
+
_TOKEN_FILE = _CONFIG_DIR / "token"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Settings(BaseSettings):
|
|
18
|
+
api_url: str = "https://api.msapling.com"
|
|
19
|
+
default_model: str = "google/gemini-flash-1.5"
|
|
20
|
+
max_tokens: int = 4096
|
|
21
|
+
temperature: float = 0.7
|
|
22
|
+
theme: str = "dark"
|
|
23
|
+
|
|
24
|
+
model_config = {"env_prefix": "MSAPLING_"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_settings() -> Settings:
|
|
28
|
+
if _CONFIG_FILE.exists():
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
31
|
+
return Settings(**data)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
return Settings()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_settings(settings: Settings) -> None:
|
|
38
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
_CONFIG_FILE.write_text(json.dumps(settings.model_dump(), indent=2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_token() -> Optional[str]:
|
|
43
|
+
if _TOKEN_FILE.exists():
|
|
44
|
+
return _TOKEN_FILE.read_text().strip()
|
|
45
|
+
return os.getenv("MSAPLING_TOKEN")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_token(token: str) -> None:
|
|
49
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
_TOKEN_FILE.write_text(token)
|
|
51
|
+
_TOKEN_FILE.chmod(0o600)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clear_token() -> None:
|
|
55
|
+
if _TOKEN_FILE.exists():
|
|
56
|
+
_TOKEN_FILE.unlink()
|
msapling_cli/local.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Local file operations for MSapling CLI.
|
|
2
|
+
|
|
3
|
+
Provides direct local filesystem access for coding workflows -
|
|
4
|
+
reading files, applying diffs, running git commands, detecting projects.
|
|
5
|
+
No server required for these operations.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_project_root(start: str = ".") -> Tuple[str, Dict]:
|
|
16
|
+
"""Walk up from start to find project root markers."""
|
|
17
|
+
current = Path(start).resolve()
|
|
18
|
+
markers = {
|
|
19
|
+
".git": "git",
|
|
20
|
+
"package.json": "node",
|
|
21
|
+
"pyproject.toml": "python",
|
|
22
|
+
"Cargo.toml": "rust",
|
|
23
|
+
"go.mod": "go",
|
|
24
|
+
"pom.xml": "java",
|
|
25
|
+
"requirements.txt": "python",
|
|
26
|
+
"tsconfig.json": "typescript",
|
|
27
|
+
}
|
|
28
|
+
while current != current.parent:
|
|
29
|
+
found = {}
|
|
30
|
+
for marker, ptype in markers.items():
|
|
31
|
+
if (current / marker).exists():
|
|
32
|
+
found[marker] = ptype
|
|
33
|
+
if found:
|
|
34
|
+
primary = list(found.values())[0]
|
|
35
|
+
return str(current), {"type": primary, "markers": list(found.keys())}
|
|
36
|
+
current = current.parent
|
|
37
|
+
return str(Path(start).resolve()), {"type": "unknown", "markers": []}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_file_tree(root: str, patterns: Optional[List[str]] = None, max_files: int = 100) -> List[Dict]:
|
|
41
|
+
"""Build a file tree of the project for context injection."""
|
|
42
|
+
patterns = patterns or ["*.py", "*.ts", "*.tsx", "*.js", "*.jsx", "*.md", "*.yaml", "*.yml", "*.toml", "*.json"]
|
|
43
|
+
skip_dirs = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build", ".next", "coverage", ".mypy_cache"}
|
|
44
|
+
|
|
45
|
+
root_path = Path(root)
|
|
46
|
+
files = []
|
|
47
|
+
for pattern in patterns:
|
|
48
|
+
for fpath in sorted(root_path.rglob(pattern)):
|
|
49
|
+
if len(files) >= max_files:
|
|
50
|
+
break
|
|
51
|
+
if any(p in skip_dirs for p in fpath.relative_to(root_path).parts):
|
|
52
|
+
continue
|
|
53
|
+
if not fpath.is_file():
|
|
54
|
+
continue
|
|
55
|
+
rel = str(fpath.relative_to(root_path)).replace("\\", "/")
|
|
56
|
+
size = fpath.stat().st_size
|
|
57
|
+
files.append({
|
|
58
|
+
"path": rel,
|
|
59
|
+
"size": size,
|
|
60
|
+
"lines": _count_lines(fpath),
|
|
61
|
+
})
|
|
62
|
+
return files
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _count_lines(fpath: Path) -> int:
|
|
66
|
+
try:
|
|
67
|
+
return len(fpath.read_text(encoding="utf-8", errors="ignore").splitlines())
|
|
68
|
+
except Exception:
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_files_as_context(root: str, files: List[Dict], max_size_kb: int = 50) -> str:
|
|
73
|
+
"""Read file contents and format as LLM context block."""
|
|
74
|
+
root_path = Path(root)
|
|
75
|
+
parts = []
|
|
76
|
+
|
|
77
|
+
# Tree summary
|
|
78
|
+
tree_lines = []
|
|
79
|
+
for f in files:
|
|
80
|
+
size_kb = f["size"] / 1024
|
|
81
|
+
tree_lines.append(f" {f['path']} ({f['lines']} lines, {size_kb:.1f}KB)")
|
|
82
|
+
parts.append("## Project Structure\n```\n" + "\n".join(tree_lines) + "\n```")
|
|
83
|
+
|
|
84
|
+
# File contents
|
|
85
|
+
for f in files:
|
|
86
|
+
if f["size"] > max_size_kb * 1024:
|
|
87
|
+
continue
|
|
88
|
+
fpath = root_path / f["path"]
|
|
89
|
+
try:
|
|
90
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
ext = Path(f["path"]).suffix.lstrip(".")
|
|
94
|
+
lang = {"py": "python", "ts": "typescript", "tsx": "tsx", "js": "javascript", "md": "markdown"}.get(ext, ext)
|
|
95
|
+
parts.append(f"## {f['path']}\n```{lang}\n{content}\n```")
|
|
96
|
+
|
|
97
|
+
return "\n\n".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def git_status(cwd: str = ".") -> str:
|
|
101
|
+
"""Get git status of the project."""
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
["git", "status", "--short"],
|
|
105
|
+
cwd=cwd, capture_output=True, text=True, timeout=10,
|
|
106
|
+
)
|
|
107
|
+
return result.stdout.strip() if result.returncode == 0 else result.stderr.strip()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return f"git not available: {e}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def git_diff(cwd: str = ".", staged: bool = False) -> str:
|
|
113
|
+
"""Get git diff."""
|
|
114
|
+
cmd = ["git", "diff"]
|
|
115
|
+
if staged:
|
|
116
|
+
cmd.append("--staged")
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=30)
|
|
119
|
+
return result.stdout.strip()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return f"git diff failed: {e}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def git_log(cwd: str = ".", count: int = 10) -> str:
|
|
125
|
+
"""Get recent git log."""
|
|
126
|
+
try:
|
|
127
|
+
result = subprocess.run(
|
|
128
|
+
["git", "log", f"--oneline", f"-{count}"],
|
|
129
|
+
cwd=cwd, capture_output=True, text=True, timeout=10,
|
|
130
|
+
)
|
|
131
|
+
return result.stdout.strip()
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return f"git log failed: {e}"
|