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,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}"