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/main.py
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
"""MSapling CLI - Multi-chat AI development environment in your terminal.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
msapling login # Authenticate
|
|
5
|
+
msapling chat "explain this code" # Quick chat
|
|
6
|
+
msapling chat -i # Interactive chat session
|
|
7
|
+
msapling edit "add type hints" main.py # LLM-driven file editing
|
|
8
|
+
msapling diff old.py new.py # Generate unified diff
|
|
9
|
+
msapling ls # List MDrive files
|
|
10
|
+
msapling cat src/main.py # View file content
|
|
11
|
+
msapling models # List available models
|
|
12
|
+
msapling projects # List projects
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.live import Live
|
|
26
|
+
from rich.markdown import Markdown
|
|
27
|
+
from rich.panel import Panel
|
|
28
|
+
from rich.status import Status
|
|
29
|
+
from rich.syntax import Syntax
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.prompt import Prompt
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
from .config import get_settings, save_settings, get_token, save_token, clear_token, Settings
|
|
35
|
+
from .api import MSaplingClient, OfflineError
|
|
36
|
+
from .tier import require_pro, require_auth, is_free_model
|
|
37
|
+
|
|
38
|
+
# Cache user info for tier checks within a session
|
|
39
|
+
_cached_user: dict = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _version_callback(value: bool):
|
|
43
|
+
if value:
|
|
44
|
+
Console().print(f"msapling-cli v{__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_callback(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version"),
|
|
51
|
+
prompt: Optional[str] = typer.Option(None, "-p", "--prompt", help="Non-interactive: run prompt and exit (headless mode)"),
|
|
52
|
+
headless_model: Optional[str] = typer.Option(None, "--model", "-m", help="Model for headless mode"),
|
|
53
|
+
output_format: str = typer.Option("text", "--output", "-o", help="Output format: text, json, stream-json"),
|
|
54
|
+
max_turns: int = typer.Option(1, "--max-turns", help="Max agent turns in headless mode"),
|
|
55
|
+
):
|
|
56
|
+
"""Launch interactive shell when no command is given. Use -p for headless mode."""
|
|
57
|
+
if ctx.invoked_subcommand is None:
|
|
58
|
+
if prompt:
|
|
59
|
+
# Headless mode: run single prompt, print result, exit
|
|
60
|
+
_run(_headless(prompt, headless_model, output_format, max_turns))
|
|
61
|
+
else:
|
|
62
|
+
from .shell import run_shell
|
|
63
|
+
asyncio.run(run_shell())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
app = typer.Typer(
|
|
67
|
+
name="msapling",
|
|
68
|
+
help="MSapling CLI - Multi-chat AI development in your terminal",
|
|
69
|
+
invoke_without_command=True,
|
|
70
|
+
callback=_default_callback,
|
|
71
|
+
)
|
|
72
|
+
console = Console()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run(coro):
|
|
76
|
+
"""Run async function in sync context."""
|
|
77
|
+
return asyncio.run(coro)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _headless(prompt: str, model: Optional[str], output_format: str, max_turns: int):
|
|
81
|
+
"""Non-interactive headless execution. Like `claude -p` or `codex exec`."""
|
|
82
|
+
import json as _json
|
|
83
|
+
settings = get_settings()
|
|
84
|
+
model_id = model or settings.default_model
|
|
85
|
+
token = get_token()
|
|
86
|
+
if not token:
|
|
87
|
+
console.print("[red]Not logged in. Run: msapling login[/red]", style="bold red")
|
|
88
|
+
raise SystemExit(1)
|
|
89
|
+
|
|
90
|
+
client = MSaplingClient()
|
|
91
|
+
try:
|
|
92
|
+
chat_id = str(uuid.uuid4())
|
|
93
|
+
if max_turns > 1:
|
|
94
|
+
# Agent mode: run tool loop
|
|
95
|
+
from .agent import run_agent_loop
|
|
96
|
+
from .local import detect_project_root
|
|
97
|
+
project_root, _ = detect_project_root(".")
|
|
98
|
+
messages: list = []
|
|
99
|
+
result = await run_agent_loop(
|
|
100
|
+
client=client, prompt=prompt, model=model_id,
|
|
101
|
+
project_root=project_root, messages=messages,
|
|
102
|
+
on_text=None,
|
|
103
|
+
)
|
|
104
|
+
if output_format == "json":
|
|
105
|
+
print(_json.dumps({"response": result, "model": model_id}))
|
|
106
|
+
else:
|
|
107
|
+
print(result)
|
|
108
|
+
else:
|
|
109
|
+
# Single-shot streaming
|
|
110
|
+
parts = []
|
|
111
|
+
usage_data = {}
|
|
112
|
+
async for chunk in client.stream_chat(
|
|
113
|
+
chat_id=chat_id, prompt=prompt, model=model_id,
|
|
114
|
+
):
|
|
115
|
+
content = chunk.get("content", "")
|
|
116
|
+
if content:
|
|
117
|
+
parts.append(content)
|
|
118
|
+
if output_format == "stream-json":
|
|
119
|
+
print(_json.dumps({"type": "content", "text": content}))
|
|
120
|
+
if chunk.get("type") == "usage":
|
|
121
|
+
usage_data = chunk
|
|
122
|
+
response = "".join(parts)
|
|
123
|
+
if output_format == "json":
|
|
124
|
+
print(_json.dumps({
|
|
125
|
+
"response": response, "model": model_id,
|
|
126
|
+
"tokens": usage_data.get("usage", {}).get("total_tokens", 0),
|
|
127
|
+
"cost": usage_data.get("cost", 0),
|
|
128
|
+
}))
|
|
129
|
+
elif output_format == "stream-json":
|
|
130
|
+
print(_json.dumps({"type": "done", "tokens": usage_data.get("usage", {}).get("total_tokens", 0)}))
|
|
131
|
+
else:
|
|
132
|
+
print(response)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
if output_format == "json":
|
|
135
|
+
print(_json.dumps({"error": str(e)}))
|
|
136
|
+
else:
|
|
137
|
+
console.print(f"[red]{e}[/red]")
|
|
138
|
+
raise SystemExit(1)
|
|
139
|
+
finally:
|
|
140
|
+
await client.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_user_or_exit() -> dict:
|
|
144
|
+
"""Fetch user info (cached). Exits if not authenticated. Handles offline."""
|
|
145
|
+
require_auth(get_token())
|
|
146
|
+
if _cached_user:
|
|
147
|
+
return _cached_user
|
|
148
|
+
|
|
149
|
+
async def _fetch():
|
|
150
|
+
client = MSaplingClient()
|
|
151
|
+
try:
|
|
152
|
+
user = await client.me()
|
|
153
|
+
_cached_user.update(user)
|
|
154
|
+
return user
|
|
155
|
+
except OfflineError:
|
|
156
|
+
console.print("[red]Cannot reach MSapling API. Check your connection or use offline commands (scan, context, git).[/red]")
|
|
157
|
+
raise SystemExit(1)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
console.print(f"[red]Auth failed: {e}[/red]")
|
|
160
|
+
raise SystemExit(1)
|
|
161
|
+
finally:
|
|
162
|
+
await client.close()
|
|
163
|
+
|
|
164
|
+
return _run(_fetch())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─── Auth ─────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def register(
|
|
171
|
+
email: str = typer.Option(..., prompt=True),
|
|
172
|
+
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
|
|
173
|
+
api_url: Optional[str] = typer.Option(None, help="API URL override"),
|
|
174
|
+
):
|
|
175
|
+
"""Create a new MSapling account from the terminal."""
|
|
176
|
+
async def _register():
|
|
177
|
+
client = MSaplingClient(api_url=api_url)
|
|
178
|
+
try:
|
|
179
|
+
http = await client._get_client()
|
|
180
|
+
resp = await http.post("/auth/register", json={
|
|
181
|
+
"email": email,
|
|
182
|
+
"password": password,
|
|
183
|
+
})
|
|
184
|
+
if resp.status_code in (200, 201):
|
|
185
|
+
console.print(f"[green]Account created for {email}[/green]")
|
|
186
|
+
console.print("[dim]Logging in...[/dim]")
|
|
187
|
+
# Auto-login after register
|
|
188
|
+
await client.login(email, password)
|
|
189
|
+
if client.token:
|
|
190
|
+
save_token(client.token)
|
|
191
|
+
if api_url:
|
|
192
|
+
settings = get_settings()
|
|
193
|
+
settings.api_url = api_url
|
|
194
|
+
save_settings(settings)
|
|
195
|
+
user = await client.me()
|
|
196
|
+
console.print(f"[green]Logged in as {user.get('email', email)}[/green]")
|
|
197
|
+
console.print(f" Tier: {user.get('tier', 'free')}")
|
|
198
|
+
console.print(f" Fuel: ${user.get('fuel_credits', 0):.4f}")
|
|
199
|
+
elif resp.status_code == 400:
|
|
200
|
+
detail = resp.json().get("detail", "Registration failed")
|
|
201
|
+
console.print(f"[red]{detail}[/red]")
|
|
202
|
+
elif resp.status_code == 409:
|
|
203
|
+
console.print(f"[yellow]Account already exists for {email}. Use: msapling login[/yellow]")
|
|
204
|
+
else:
|
|
205
|
+
console.print(f"[red]Registration failed (HTTP {resp.status_code})[/red]")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
console.print(f"[red]Registration failed: {e}[/red]")
|
|
208
|
+
finally:
|
|
209
|
+
await client.close()
|
|
210
|
+
|
|
211
|
+
_run(_register())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def login(
|
|
216
|
+
email: str = typer.Option(..., prompt=True),
|
|
217
|
+
password: str = typer.Option(..., prompt=True, hide_input=True),
|
|
218
|
+
api_url: Optional[str] = typer.Option(None, help="API URL override"),
|
|
219
|
+
):
|
|
220
|
+
"""Authenticate with MSapling backend."""
|
|
221
|
+
async def _login():
|
|
222
|
+
client = MSaplingClient(api_url=api_url)
|
|
223
|
+
try:
|
|
224
|
+
result = await client.login(email, password)
|
|
225
|
+
if client.token:
|
|
226
|
+
save_token(client.token)
|
|
227
|
+
if api_url:
|
|
228
|
+
settings = get_settings()
|
|
229
|
+
settings.api_url = api_url
|
|
230
|
+
save_settings(settings)
|
|
231
|
+
user = await client.me()
|
|
232
|
+
console.print(f"[green]Logged in as {user.get('email', email)}[/green]")
|
|
233
|
+
console.print(f" Tier: {user.get('tier', 'free')}")
|
|
234
|
+
console.print(f" Fuel: ${user.get('fuel_credits', 0):.4f}")
|
|
235
|
+
else:
|
|
236
|
+
console.print("[red]Login failed - no token received[/red]")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
console.print(f"[red]Login failed: {e}[/red]")
|
|
239
|
+
finally:
|
|
240
|
+
await client.close()
|
|
241
|
+
|
|
242
|
+
_run(_login())
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command()
|
|
246
|
+
def logout():
|
|
247
|
+
"""Clear saved authentication."""
|
|
248
|
+
clear_token()
|
|
249
|
+
console.print("[yellow]Logged out[/yellow]")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command()
|
|
253
|
+
def whoami():
|
|
254
|
+
"""Show current user info."""
|
|
255
|
+
async def _whoami():
|
|
256
|
+
client = MSaplingClient()
|
|
257
|
+
try:
|
|
258
|
+
user = await client.me()
|
|
259
|
+
table = Table(title="Account")
|
|
260
|
+
table.add_column("Field", style="cyan")
|
|
261
|
+
table.add_column("Value")
|
|
262
|
+
table.add_row("Email", str(user.get("email", "?")))
|
|
263
|
+
table.add_row("Tier", str(user.get("tier", "free")))
|
|
264
|
+
table.add_row("Fuel Credits", f"${user.get('fuel_credits', 0):.4f}")
|
|
265
|
+
table.add_row("Pro", str(user.get("is_pro", False)))
|
|
266
|
+
console.print(table)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
console.print(f"[red]Not authenticated: {e}[/red]")
|
|
269
|
+
finally:
|
|
270
|
+
await client.close()
|
|
271
|
+
|
|
272
|
+
_run(_whoami())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ─── Chat ─────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def chat(
|
|
279
|
+
prompt: Optional[str] = typer.Argument(None, help="Message to send"),
|
|
280
|
+
model: Optional[str] = typer.Option(None, "-m", help="Model ID"),
|
|
281
|
+
project: Optional[str] = typer.Option(None, "-p", help="Project name"),
|
|
282
|
+
interactive: bool = typer.Option(False, "-i", help="Interactive session"),
|
|
283
|
+
agent: bool = typer.Option(False, "-a", help="Enable agent mode"),
|
|
284
|
+
):
|
|
285
|
+
"""Chat with an LLM. Use -i for interactive session.
|
|
286
|
+
|
|
287
|
+
Free tier: Flash, Haiku, Mini, Llama, Mistral models only.
|
|
288
|
+
Pro tier: All models + unlimited messages.
|
|
289
|
+
"""
|
|
290
|
+
from .completer import resolve_model
|
|
291
|
+
settings = get_settings()
|
|
292
|
+
raw_model = model or settings.default_model
|
|
293
|
+
model_id, was_fuzzy = resolve_model(raw_model)
|
|
294
|
+
if was_fuzzy and model:
|
|
295
|
+
console.print(f"[dim]Model: {model} -> {model_id}[/dim]")
|
|
296
|
+
user = _get_user_or_exit()
|
|
297
|
+
require_pro(user, "chat", model_id)
|
|
298
|
+
chat_id = str(uuid.uuid4())
|
|
299
|
+
|
|
300
|
+
async def _chat_once(msg: str):
|
|
301
|
+
client = MSaplingClient()
|
|
302
|
+
try:
|
|
303
|
+
parts = []
|
|
304
|
+
with Live(console=console, refresh_per_second=15) as live:
|
|
305
|
+
async for chunk in client.stream_chat(
|
|
306
|
+
chat_id=chat_id,
|
|
307
|
+
prompt=msg,
|
|
308
|
+
model=model_id,
|
|
309
|
+
project_name=project,
|
|
310
|
+
agent_mode=agent,
|
|
311
|
+
):
|
|
312
|
+
content = chunk.get("content", "")
|
|
313
|
+
if content:
|
|
314
|
+
parts.append(content)
|
|
315
|
+
text = "".join(parts)
|
|
316
|
+
live.update(Markdown(text))
|
|
317
|
+
if chunk.get("type") == "usage":
|
|
318
|
+
usage = chunk.get("usage", {})
|
|
319
|
+
tokens = usage.get("total_tokens", 0)
|
|
320
|
+
cost = chunk.get("cost", 0)
|
|
321
|
+
console.print(f"\n[dim]({tokens} tokens, ${cost:.4f})[/dim]")
|
|
322
|
+
if not parts:
|
|
323
|
+
console.print("[yellow]No response received[/yellow]")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
326
|
+
finally:
|
|
327
|
+
await client.close()
|
|
328
|
+
|
|
329
|
+
if interactive:
|
|
330
|
+
console.print(f"[cyan]MSapling Chat[/cyan] | model: {model_id} | type 'exit' to quit")
|
|
331
|
+
console.print("─" * 60)
|
|
332
|
+
while True:
|
|
333
|
+
try:
|
|
334
|
+
msg = Prompt.ask("[bold green]You[/bold green]")
|
|
335
|
+
if msg.lower() in ("exit", "quit", "q"):
|
|
336
|
+
break
|
|
337
|
+
if not msg.strip():
|
|
338
|
+
continue
|
|
339
|
+
_run(_chat_once(msg))
|
|
340
|
+
console.print()
|
|
341
|
+
except (KeyboardInterrupt, EOFError):
|
|
342
|
+
break
|
|
343
|
+
console.print("[yellow]Session ended[/yellow]")
|
|
344
|
+
elif prompt:
|
|
345
|
+
_run(_chat_once(prompt))
|
|
346
|
+
else:
|
|
347
|
+
console.print("[yellow]Provide a prompt or use -i for interactive mode[/yellow]")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ─── Multi-Chat & Swarm ───────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def multi(
|
|
354
|
+
prompt: str = typer.Argument(..., help="Prompt to send to all models"),
|
|
355
|
+
models: str = typer.Option(
|
|
356
|
+
"google/gemini-flash-1.5,anthropic/claude-3-haiku,openai/gpt-4o-mini",
|
|
357
|
+
"-m", help="Comma-separated model IDs",
|
|
358
|
+
),
|
|
359
|
+
):
|
|
360
|
+
"""Send the same prompt to multiple models in parallel. Compare responses side-by-side.
|
|
361
|
+
|
|
362
|
+
Requires Pro subscription.
|
|
363
|
+
"""
|
|
364
|
+
user = _get_user_or_exit()
|
|
365
|
+
require_pro(user, "multi")
|
|
366
|
+
model_list = [m.strip() for m in models.split(",") if m.strip()]
|
|
367
|
+
async def _multi():
|
|
368
|
+
client = MSaplingClient()
|
|
369
|
+
try:
|
|
370
|
+
with Status(f"Querying {len(model_list)} models in parallel...", console=console):
|
|
371
|
+
results = await client.multi_chat(prompt, model_list)
|
|
372
|
+
for r in results:
|
|
373
|
+
status_color = "green" if r["status"] == "ok" else "red"
|
|
374
|
+
console.print(Panel(
|
|
375
|
+
Markdown(r["response"][:3000]) if r["response"] else f"[red]{r.get('error', 'No response')}[/red]",
|
|
376
|
+
title=f"[{status_color}]{r['model']}[/{status_color}]",
|
|
377
|
+
border_style=status_color,
|
|
378
|
+
))
|
|
379
|
+
except Exception as e:
|
|
380
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
381
|
+
finally:
|
|
382
|
+
await client.close()
|
|
383
|
+
|
|
384
|
+
_run(_multi())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command()
|
|
388
|
+
def swarm(
|
|
389
|
+
prompt: str = typer.Argument(..., help="Task for the swarm"),
|
|
390
|
+
models: Optional[str] = typer.Option(None, "-m", help="Comma-separated models (default: gemini,claude,gpt)"),
|
|
391
|
+
judge: Optional[str] = typer.Option(None, "-j", help="Judge model for synthesis"),
|
|
392
|
+
):
|
|
393
|
+
"""Run a multi-model swarm: N models answer in parallel, then a judge synthesizes.
|
|
394
|
+
|
|
395
|
+
Requires Pro subscription.
|
|
396
|
+
|
|
397
|
+
Example:
|
|
398
|
+
msapling swarm "explain async/await" -m "gpt-4o,claude-3.5-sonnet,gemini-pro"
|
|
399
|
+
"""
|
|
400
|
+
user = _get_user_or_exit()
|
|
401
|
+
require_pro(user, "swarm")
|
|
402
|
+
model_list = [m.strip() for m in models.split(",") if m.strip()] if models else None
|
|
403
|
+
|
|
404
|
+
async def _swarm():
|
|
405
|
+
client = MSaplingClient()
|
|
406
|
+
try:
|
|
407
|
+
with Status("Running swarm (parallel models + judge synthesis)...", console=console):
|
|
408
|
+
result = await client.swarm(prompt, models=model_list, synthesize_model=judge)
|
|
409
|
+
|
|
410
|
+
# Show individual agent responses
|
|
411
|
+
for r in result["agent_responses"]:
|
|
412
|
+
status = "[green]OK[/green]" if r["status"] == "ok" else "[red]FAIL[/red]"
|
|
413
|
+
console.print(f"\n[dim]── {r['model']} ({status}) ──[/dim]")
|
|
414
|
+
if r["response"]:
|
|
415
|
+
console.print(Markdown(r["response"][:1500]))
|
|
416
|
+
|
|
417
|
+
# Show synthesis
|
|
418
|
+
console.print(Panel(
|
|
419
|
+
Markdown(result["synthesis"]),
|
|
420
|
+
title=f"[bold cyan]Synthesis by {result['judge_model']}[/bold cyan]",
|
|
421
|
+
border_style="cyan",
|
|
422
|
+
))
|
|
423
|
+
console.print(f"[dim]Models used: {', '.join(result['models_used'])}[/dim]")
|
|
424
|
+
except Exception as e:
|
|
425
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
426
|
+
finally:
|
|
427
|
+
await client.close()
|
|
428
|
+
|
|
429
|
+
_run(_swarm())
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ─── Code Editing ─────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
@app.command()
|
|
435
|
+
def edit(
|
|
436
|
+
instruction: str = typer.Argument(..., help="What to change"),
|
|
437
|
+
filepath: str = typer.Argument(..., help="File to edit"),
|
|
438
|
+
model: Optional[str] = typer.Option(None, "-m", help="Model ID"),
|
|
439
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show diff without applying"),
|
|
440
|
+
):
|
|
441
|
+
"""Edit a file using natural language instructions.
|
|
442
|
+
|
|
443
|
+
Requires Pro subscription.
|
|
444
|
+
"""
|
|
445
|
+
user = _get_user_or_exit()
|
|
446
|
+
require_pro(user, "edit")
|
|
447
|
+
path = Path(filepath)
|
|
448
|
+
if not path.exists():
|
|
449
|
+
console.print(f"[red]File not found: {filepath}[/red]")
|
|
450
|
+
raise typer.Exit(1)
|
|
451
|
+
|
|
452
|
+
original = path.read_text(encoding="utf-8")
|
|
453
|
+
settings = get_settings()
|
|
454
|
+
model_id = model or settings.default_model
|
|
455
|
+
chat_id = str(uuid.uuid4())
|
|
456
|
+
|
|
457
|
+
prompt_text = (
|
|
458
|
+
f"Edit the following file according to this instruction: {instruction}\n\n"
|
|
459
|
+
f"File: {filepath}\n```\n{original}\n```\n\n"
|
|
460
|
+
f"Return ONLY a unified diff (no explanation). Start with --- and +++."
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def _edit():
|
|
464
|
+
client = MSaplingClient()
|
|
465
|
+
try:
|
|
466
|
+
parts = []
|
|
467
|
+
console.print(f"[cyan]Generating edit for {filepath}...[/cyan]")
|
|
468
|
+
async for chunk in client.stream_chat(
|
|
469
|
+
chat_id=chat_id,
|
|
470
|
+
prompt=prompt_text,
|
|
471
|
+
model=model_id,
|
|
472
|
+
):
|
|
473
|
+
content = chunk.get("content", "")
|
|
474
|
+
if content:
|
|
475
|
+
parts.append(content)
|
|
476
|
+
|
|
477
|
+
diff_text = "".join(parts)
|
|
478
|
+
|
|
479
|
+
# Show diff
|
|
480
|
+
console.print(Panel(
|
|
481
|
+
Syntax(diff_text, "diff", theme="monokai"),
|
|
482
|
+
title=f"Proposed changes to {filepath}",
|
|
483
|
+
))
|
|
484
|
+
|
|
485
|
+
if dry_run:
|
|
486
|
+
console.print("[yellow]Dry run - no changes applied[/yellow]")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
# Apply via MLineage
|
|
490
|
+
result = await client.apply_diff(original, diff_text)
|
|
491
|
+
if result.get("success") or result.get("applied_content"):
|
|
492
|
+
new_content = result.get("applied_content", "")
|
|
493
|
+
if new_content:
|
|
494
|
+
path.write_text(new_content, encoding="utf-8")
|
|
495
|
+
console.print(f"[green]Applied changes to {filepath}[/green]")
|
|
496
|
+
else:
|
|
497
|
+
console.print("[yellow]Diff parsed but no content returned[/yellow]")
|
|
498
|
+
else:
|
|
499
|
+
console.print(f"[red]Failed to apply diff: {result.get('error', 'unknown')}[/red]")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
502
|
+
finally:
|
|
503
|
+
await client.close()
|
|
504
|
+
|
|
505
|
+
_run(_edit())
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ─── Diff ─────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
@app.command()
|
|
511
|
+
def diff(
|
|
512
|
+
old_file: str = typer.Argument(..., help="Original file"),
|
|
513
|
+
new_file: str = typer.Argument(..., help="Modified file"),
|
|
514
|
+
):
|
|
515
|
+
"""Generate a unified diff between two files."""
|
|
516
|
+
old_path, new_path = Path(old_file), Path(new_file)
|
|
517
|
+
if not old_path.exists():
|
|
518
|
+
console.print(f"[red]File not found: {old_file}[/red]")
|
|
519
|
+
raise typer.Exit(1)
|
|
520
|
+
if not new_path.exists():
|
|
521
|
+
console.print(f"[red]File not found: {new_file}[/red]")
|
|
522
|
+
raise typer.Exit(1)
|
|
523
|
+
|
|
524
|
+
async def _diff():
|
|
525
|
+
client = MSaplingClient()
|
|
526
|
+
try:
|
|
527
|
+
result = await client.generate_diff(
|
|
528
|
+
old_content=old_path.read_text(encoding="utf-8"),
|
|
529
|
+
new_content=new_path.read_text(encoding="utf-8"),
|
|
530
|
+
filename=old_file,
|
|
531
|
+
)
|
|
532
|
+
diff_text = result.get("diff", result.get("unified_diff", ""))
|
|
533
|
+
if diff_text:
|
|
534
|
+
console.print(Syntax(diff_text, "diff", theme="monokai"))
|
|
535
|
+
else:
|
|
536
|
+
console.print("[yellow]Files are identical[/yellow]")
|
|
537
|
+
except Exception as e:
|
|
538
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
539
|
+
finally:
|
|
540
|
+
await client.close()
|
|
541
|
+
|
|
542
|
+
_run(_diff())
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ─── File Operations ──────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
@app.command(name="ls")
|
|
548
|
+
def list_files(
|
|
549
|
+
project_id: str = typer.Argument("default", help="Project ID"),
|
|
550
|
+
path: str = typer.Option("/", help="Directory path"),
|
|
551
|
+
):
|
|
552
|
+
"""List files in an MDrive project."""
|
|
553
|
+
async def _ls():
|
|
554
|
+
client = MSaplingClient()
|
|
555
|
+
try:
|
|
556
|
+
files = await client.list_files(project_id, path)
|
|
557
|
+
if not files:
|
|
558
|
+
console.print("[yellow]No files found[/yellow]")
|
|
559
|
+
return
|
|
560
|
+
table = Table(title=f"Files in {project_id}:{path}")
|
|
561
|
+
table.add_column("Name", style="cyan")
|
|
562
|
+
table.add_column("Size", justify="right")
|
|
563
|
+
table.add_column("Modified")
|
|
564
|
+
for f in files:
|
|
565
|
+
name = f.get("name", f.get("file_path", "?"))
|
|
566
|
+
size = f.get("size", f.get("size_bytes", 0))
|
|
567
|
+
modified = f.get("modified", f.get("updated_at", ""))
|
|
568
|
+
size_str = f"{size / 1024:.1f} KB" if size > 1024 else f"{size} B"
|
|
569
|
+
table.add_row(name, size_str, str(modified)[:19])
|
|
570
|
+
console.print(table)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
573
|
+
finally:
|
|
574
|
+
await client.close()
|
|
575
|
+
|
|
576
|
+
_run(_ls())
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.command(name="cat")
|
|
580
|
+
def read_file(
|
|
581
|
+
project_id: str = typer.Argument(..., help="Project ID"),
|
|
582
|
+
filepath: str = typer.Argument(..., help="File path"),
|
|
583
|
+
):
|
|
584
|
+
"""Read a file from MDrive."""
|
|
585
|
+
async def _cat():
|
|
586
|
+
client = MSaplingClient()
|
|
587
|
+
try:
|
|
588
|
+
content = await client.read_file(project_id, filepath)
|
|
589
|
+
ext = Path(filepath).suffix.lstrip(".")
|
|
590
|
+
lang_map = {"py": "python", "ts": "typescript", "js": "javascript", "tsx": "tsx", "md": "markdown"}
|
|
591
|
+
lang = lang_map.get(ext, ext)
|
|
592
|
+
console.print(Syntax(content, lang, theme="monokai", line_numbers=True))
|
|
593
|
+
except Exception as e:
|
|
594
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
595
|
+
finally:
|
|
596
|
+
await client.close()
|
|
597
|
+
|
|
598
|
+
_run(_cat())
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ─── Models & Projects ────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
@app.command()
|
|
604
|
+
def models():
|
|
605
|
+
"""List available LLM models."""
|
|
606
|
+
async def _models():
|
|
607
|
+
client = MSaplingClient()
|
|
608
|
+
try:
|
|
609
|
+
model_list = await client.get_models()
|
|
610
|
+
table = Table(title="Available Models")
|
|
611
|
+
table.add_column("Model ID", style="cyan")
|
|
612
|
+
table.add_column("Provider")
|
|
613
|
+
table.add_column("Context", justify="right")
|
|
614
|
+
for m in model_list[:30]:
|
|
615
|
+
mid = m.get("id", m.get("model_id", "?"))
|
|
616
|
+
provider = mid.split("/")[0] if "/" in mid else "unknown"
|
|
617
|
+
ctx = m.get("context_length", m.get("context", "?"))
|
|
618
|
+
table.add_row(mid, provider, str(ctx))
|
|
619
|
+
console.print(table)
|
|
620
|
+
if len(model_list) > 30:
|
|
621
|
+
console.print(f"[dim]... and {len(model_list) - 30} more[/dim]")
|
|
622
|
+
except Exception as e:
|
|
623
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
624
|
+
finally:
|
|
625
|
+
await client.close()
|
|
626
|
+
|
|
627
|
+
_run(_models())
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@app.command()
|
|
631
|
+
def projects():
|
|
632
|
+
"""List your projects."""
|
|
633
|
+
async def _projects():
|
|
634
|
+
client = MSaplingClient()
|
|
635
|
+
try:
|
|
636
|
+
proj_list = await client.list_projects()
|
|
637
|
+
table = Table(title="Projects")
|
|
638
|
+
table.add_column("Name", style="cyan")
|
|
639
|
+
table.add_column("Tokens Used", justify="right")
|
|
640
|
+
table.add_column("Cost", justify="right")
|
|
641
|
+
for p in proj_list:
|
|
642
|
+
name = p.get("name", "?")
|
|
643
|
+
tokens = p.get("tokens_used", 0)
|
|
644
|
+
cost = p.get("cost_used", 0)
|
|
645
|
+
table.add_row(name, f"{tokens:,}", f"${float(cost):.4f}")
|
|
646
|
+
console.print(table)
|
|
647
|
+
except Exception as e:
|
|
648
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
649
|
+
finally:
|
|
650
|
+
await client.close()
|
|
651
|
+
|
|
652
|
+
_run(_projects())
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ─── Config ───────────────────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
@app.command()
|
|
658
|
+
def config(
|
|
659
|
+
key: Optional[str] = typer.Argument(None, help="Setting key"),
|
|
660
|
+
value: Optional[str] = typer.Argument(None, help="Setting value"),
|
|
661
|
+
):
|
|
662
|
+
"""View or set configuration."""
|
|
663
|
+
settings = get_settings()
|
|
664
|
+
if key and value:
|
|
665
|
+
if hasattr(settings, key):
|
|
666
|
+
setattr(settings, key, value)
|
|
667
|
+
save_settings(settings)
|
|
668
|
+
console.print(f"[green]Set {key} = {value}[/green]")
|
|
669
|
+
else:
|
|
670
|
+
console.print(f"[red]Unknown setting: {key}[/red]")
|
|
671
|
+
else:
|
|
672
|
+
table = Table(title="Configuration")
|
|
673
|
+
table.add_column("Key", style="cyan")
|
|
674
|
+
table.add_column("Value")
|
|
675
|
+
for k, v in settings.model_dump().items():
|
|
676
|
+
table.add_row(k, str(v))
|
|
677
|
+
table.add_row("authenticated", str(bool(get_token())))
|
|
678
|
+
console.print(table)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ─── Local Project Operations (no server needed) ─────────────────────
|
|
682
|
+
|
|
683
|
+
@app.command()
|
|
684
|
+
def context(
|
|
685
|
+
path: str = typer.Argument(".", help="Project directory"),
|
|
686
|
+
patterns: Optional[str] = typer.Option(None, help="Glob patterns (comma-separated)"),
|
|
687
|
+
max_files: int = typer.Option(30, help="Max files to include"),
|
|
688
|
+
output: Optional[str] = typer.Option(None, "-o", help="Save context to file"),
|
|
689
|
+
):
|
|
690
|
+
"""Scan a local project and build LLM-ready context.
|
|
691
|
+
|
|
692
|
+
This runs locally — no server needed. Use this to load any codebase
|
|
693
|
+
into a context block you can paste into chat or pipe to 'msapling chat'.
|
|
694
|
+
"""
|
|
695
|
+
from .local import detect_project_root, build_file_tree, read_files_as_context
|
|
696
|
+
|
|
697
|
+
root, info = detect_project_root(path)
|
|
698
|
+
console.print(f"[cyan]Project root:[/cyan] {root}")
|
|
699
|
+
console.print(f"[cyan]Type:[/cyan] {info['type']} | Markers: {', '.join(info['markers'])}")
|
|
700
|
+
|
|
701
|
+
pattern_list = patterns.split(",") if patterns else None
|
|
702
|
+
files = build_file_tree(root, patterns=pattern_list, max_files=max_files)
|
|
703
|
+
console.print(f"[cyan]Files found:[/cyan] {len(files)}")
|
|
704
|
+
|
|
705
|
+
context_block = read_files_as_context(root, files)
|
|
706
|
+
token_est = len(context_block) // 4
|
|
707
|
+
console.print(f"[cyan]Context size:[/cyan] ~{token_est:,} tokens ({len(context_block):,} chars)")
|
|
708
|
+
|
|
709
|
+
if output:
|
|
710
|
+
Path(output).write_text(context_block, encoding="utf-8")
|
|
711
|
+
console.print(f"[green]Saved to {output}[/green]")
|
|
712
|
+
else:
|
|
713
|
+
console.print(Panel(
|
|
714
|
+
f"[dim]{context_block[:500]}...[/dim]" if len(context_block) > 500 else context_block,
|
|
715
|
+
title="Context Preview (first 500 chars)",
|
|
716
|
+
))
|
|
717
|
+
console.print(f"\n[yellow]Use -o context.md to save full context, or pipe to chat:[/yellow]")
|
|
718
|
+
console.print(f" msapling context . -o ctx.md && msapling chat -i")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@app.command()
|
|
722
|
+
def git(
|
|
723
|
+
args: str = typer.Argument(..., help="Git arguments (e.g. 'status', 'log --oneline -5')"),
|
|
724
|
+
cwd: str = typer.Option(".", help="Working directory"),
|
|
725
|
+
):
|
|
726
|
+
"""Run git commands locally with pretty output."""
|
|
727
|
+
from .local import git_status, git_diff, git_log
|
|
728
|
+
import subprocess
|
|
729
|
+
|
|
730
|
+
parts = args.split()
|
|
731
|
+
subcmd = parts[0] if parts else ""
|
|
732
|
+
|
|
733
|
+
# Shortcuts for common commands
|
|
734
|
+
if subcmd == "s":
|
|
735
|
+
console.print(Syntax(git_status(cwd), "diff", theme="monokai"))
|
|
736
|
+
return
|
|
737
|
+
if subcmd == "d":
|
|
738
|
+
console.print(Syntax(git_diff(cwd), "diff", theme="monokai"))
|
|
739
|
+
return
|
|
740
|
+
if subcmd == "l":
|
|
741
|
+
console.print(git_log(cwd))
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
result = subprocess.run(
|
|
746
|
+
["git"] + parts,
|
|
747
|
+
cwd=cwd, capture_output=True, text=True, timeout=30,
|
|
748
|
+
)
|
|
749
|
+
if result.stdout:
|
|
750
|
+
if subcmd in ("diff", "show", "log"):
|
|
751
|
+
console.print(Syntax(result.stdout, "diff", theme="monokai"))
|
|
752
|
+
else:
|
|
753
|
+
console.print(result.stdout)
|
|
754
|
+
if result.stderr and result.returncode != 0:
|
|
755
|
+
console.print(f"[red]{result.stderr}[/red]")
|
|
756
|
+
except subprocess.TimeoutExpired:
|
|
757
|
+
console.print("[red]Git command timed out (30s)[/red]")
|
|
758
|
+
except FileNotFoundError:
|
|
759
|
+
console.print("[red]Git not found — install git first[/red]")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@app.command()
|
|
763
|
+
def scan(
|
|
764
|
+
path: str = typer.Argument(".", help="Directory to scan"),
|
|
765
|
+
):
|
|
766
|
+
"""Detect project type, language, framework, and file stats."""
|
|
767
|
+
from .local import detect_project_root, build_file_tree
|
|
768
|
+
|
|
769
|
+
root, info = detect_project_root(path)
|
|
770
|
+
files = build_file_tree(root, max_files=500)
|
|
771
|
+
|
|
772
|
+
# Count by extension
|
|
773
|
+
ext_counts: dict = {}
|
|
774
|
+
for f in files:
|
|
775
|
+
ext = Path(f["path"]).suffix or "(no ext)"
|
|
776
|
+
ext_counts[ext] = ext_counts.get(ext, 0) + 1
|
|
777
|
+
|
|
778
|
+
table = Table(title=f"Project: {Path(root).name}")
|
|
779
|
+
table.add_column("Property", style="cyan")
|
|
780
|
+
table.add_column("Value")
|
|
781
|
+
table.add_row("Root", root)
|
|
782
|
+
table.add_row("Type", info["type"])
|
|
783
|
+
table.add_row("Markers", ", ".join(info["markers"]) or "none")
|
|
784
|
+
table.add_row("Total Files", str(len(files)))
|
|
785
|
+
table.add_row("Total Lines", f"{sum(f['lines'] for f in files):,}")
|
|
786
|
+
console.print(table)
|
|
787
|
+
|
|
788
|
+
if ext_counts:
|
|
789
|
+
ext_table = Table(title="File Types")
|
|
790
|
+
ext_table.add_column("Extension", style="cyan")
|
|
791
|
+
ext_table.add_column("Count", justify="right")
|
|
792
|
+
for ext, count in sorted(ext_counts.items(), key=lambda x: -x[1])[:15]:
|
|
793
|
+
ext_table.add_row(ext, str(count))
|
|
794
|
+
console.print(ext_table)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@app.command()
|
|
798
|
+
def shell(
|
|
799
|
+
model: Optional[str] = typer.Option(None, "-m", help="Model ID"),
|
|
800
|
+
project: Optional[str] = typer.Option(None, "-p", help="Project name"),
|
|
801
|
+
resume: Optional[str] = typer.Option(None, "--resume", "-r", help="Resume session (ID or number)"),
|
|
802
|
+
):
|
|
803
|
+
"""Start interactive shell (like Claude Code / Gemini CLI).
|
|
804
|
+
|
|
805
|
+
Full-featured coding environment with slash commands:
|
|
806
|
+
/help, /model, /cost, /read, /grep, /run, /git, /plan, /save, /resume
|
|
807
|
+
|
|
808
|
+
Examples:
|
|
809
|
+
msapling shell # start new session
|
|
810
|
+
msapling shell -m gpt-4o # with specific model
|
|
811
|
+
msapling shell --resume # resume last session
|
|
812
|
+
msapling shell -r 1 # resume session #1
|
|
813
|
+
"""
|
|
814
|
+
from .shell import run_shell
|
|
815
|
+
_run(run_shell(model=model, project=project, resume_id=resume))
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@app.command(name="mcp-serve")
|
|
819
|
+
def mcp_serve():
|
|
820
|
+
"""Start MSapling as an MCP server (for Claude Code, Cursor, etc).
|
|
821
|
+
|
|
822
|
+
This runs as a subprocess that communicates via stdin/stdout using
|
|
823
|
+
the Model Context Protocol. Add to your tool's MCP config:
|
|
824
|
+
|
|
825
|
+
Claude Code (~/.claude/settings.json):
|
|
826
|
+
"mcpServers": { "msapling": { "command": "msapling", "args": ["mcp-serve"] } }
|
|
827
|
+
|
|
828
|
+
Cursor (.cursor/mcp.json):
|
|
829
|
+
{ "msapling": { "command": "msapling", "args": ["mcp-serve"] } }
|
|
830
|
+
"""
|
|
831
|
+
from .mcp.server import serve
|
|
832
|
+
serve()
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@app.command()
|
|
836
|
+
def benchmark(
|
|
837
|
+
prompt: str = typer.Argument("Explain the difference between a stack and a queue.", help="Prompt to benchmark"),
|
|
838
|
+
models: str = typer.Option(
|
|
839
|
+
"google/gemini-2.0-flash-001,anthropic/claude-3-haiku,openai/gpt-4o-mini",
|
|
840
|
+
"-m", help="Comma-separated model IDs to compare",
|
|
841
|
+
),
|
|
842
|
+
):
|
|
843
|
+
"""Run a model benchmark from the CLI. Compares speed, cost, and quality.
|
|
844
|
+
|
|
845
|
+
Sends the same prompt to multiple models and shows results side-by-side
|
|
846
|
+
with tokens/sec, cost, and response preview.
|
|
847
|
+
|
|
848
|
+
Example:
|
|
849
|
+
msapling benchmark "explain recursion" -m "gpt-4o,claude-3.5-sonnet,gemini-flash"
|
|
850
|
+
"""
|
|
851
|
+
from .completer import resolve_model
|
|
852
|
+
user = _get_user_or_exit()
|
|
853
|
+
require_pro(user, "benchmark")
|
|
854
|
+
model_list = [resolve_model(m.strip())[0] for m in models.split(",") if m.strip()]
|
|
855
|
+
|
|
856
|
+
async def _benchmark():
|
|
857
|
+
import time as _time
|
|
858
|
+
client = MSaplingClient()
|
|
859
|
+
try:
|
|
860
|
+
results = []
|
|
861
|
+
for mid in model_list:
|
|
862
|
+
model_short = mid.split("/")[-1] if "/" in mid else mid
|
|
863
|
+
with Status(f"Running {model_short}...", console=console):
|
|
864
|
+
t0 = _time.monotonic()
|
|
865
|
+
parts = []
|
|
866
|
+
total_tokens = 0
|
|
867
|
+
cost = 0.0
|
|
868
|
+
try:
|
|
869
|
+
async for chunk in client.stream_chat(
|
|
870
|
+
chat_id=str(uuid.uuid4()),
|
|
871
|
+
prompt=prompt,
|
|
872
|
+
model=mid,
|
|
873
|
+
):
|
|
874
|
+
content = chunk.get("content", "")
|
|
875
|
+
if content:
|
|
876
|
+
parts.append(content)
|
|
877
|
+
if chunk.get("type") == "usage":
|
|
878
|
+
usage = chunk.get("usage", {})
|
|
879
|
+
total_tokens = usage.get("total_tokens", 0)
|
|
880
|
+
cost = float(chunk.get("cost", 0))
|
|
881
|
+
elapsed = _time.monotonic() - t0
|
|
882
|
+
tps = total_tokens / elapsed if elapsed > 0 else 0
|
|
883
|
+
results.append({
|
|
884
|
+
"model": mid, "response": "".join(parts),
|
|
885
|
+
"tokens": total_tokens, "cost": cost,
|
|
886
|
+
"elapsed": elapsed, "tps": tps, "status": "ok",
|
|
887
|
+
})
|
|
888
|
+
except Exception as e:
|
|
889
|
+
results.append({
|
|
890
|
+
"model": mid, "response": "", "tokens": 0,
|
|
891
|
+
"cost": 0, "elapsed": 0, "tps": 0,
|
|
892
|
+
"status": f"error: {e}",
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
# Results table
|
|
896
|
+
table = Table(title="Benchmark Results")
|
|
897
|
+
table.add_column("Model", style="cyan")
|
|
898
|
+
table.add_column("Tokens", justify="right")
|
|
899
|
+
table.add_column("TPS", justify="right")
|
|
900
|
+
table.add_column("Cost", justify="right")
|
|
901
|
+
table.add_column("Time", justify="right")
|
|
902
|
+
table.add_column("Status")
|
|
903
|
+
for r in sorted(results, key=lambda x: x["tps"], reverse=True):
|
|
904
|
+
model_short = r["model"].split("/")[-1] if "/" in r["model"] else r["model"]
|
|
905
|
+
status_color = "green" if r["status"] == "ok" else "red"
|
|
906
|
+
table.add_row(
|
|
907
|
+
model_short,
|
|
908
|
+
f"{r['tokens']:,}",
|
|
909
|
+
f"{r['tps']:.1f}",
|
|
910
|
+
f"${r['cost']:.4f}",
|
|
911
|
+
f"{r['elapsed']:.1f}s",
|
|
912
|
+
f"[{status_color}]{r['status']}[/{status_color}]",
|
|
913
|
+
)
|
|
914
|
+
console.print(table)
|
|
915
|
+
|
|
916
|
+
# Show responses
|
|
917
|
+
for r in results:
|
|
918
|
+
if r["response"]:
|
|
919
|
+
model_short = r["model"].split("/")[-1] if "/" in r["model"] else r["model"]
|
|
920
|
+
console.print(Panel(
|
|
921
|
+
Markdown(r["response"][:2000]),
|
|
922
|
+
title=f"[cyan]{model_short}[/cyan] ({r['tokens']:,} tok, {r['tps']:.1f} tok/s)",
|
|
923
|
+
border_style="cyan" if r["status"] == "ok" else "red",
|
|
924
|
+
))
|
|
925
|
+
except Exception as e:
|
|
926
|
+
console.print(f"[red]Benchmark failed: {e}[/red]")
|
|
927
|
+
finally:
|
|
928
|
+
await client.close()
|
|
929
|
+
|
|
930
|
+
_run(_benchmark())
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
@app.command()
|
|
934
|
+
def completions(
|
|
935
|
+
shell_name: str = typer.Argument("bash", help="Shell: bash, zsh, fish, powershell"),
|
|
936
|
+
install: bool = typer.Option(False, "--install", help="Install completions to shell profile"),
|
|
937
|
+
):
|
|
938
|
+
"""Generate or install shell tab completions.
|
|
939
|
+
|
|
940
|
+
Examples:
|
|
941
|
+
msapling completions bash --install
|
|
942
|
+
msapling completions zsh >> ~/.zshrc
|
|
943
|
+
msapling completions fish > ~/.config/fish/completions/msapling.fish
|
|
944
|
+
msapling completions powershell >> $PROFILE
|
|
945
|
+
"""
|
|
946
|
+
import subprocess as _sp
|
|
947
|
+
shell_map = {"bash": "bash", "zsh": "zsh", "fish": "fish", "powershell": "powershell", "pwsh": "powershell"}
|
|
948
|
+
target = shell_map.get(shell_name.lower())
|
|
949
|
+
if not target:
|
|
950
|
+
console.print(f"[red]Unknown shell: {shell_name}. Options: bash, zsh, fish, powershell[/red]")
|
|
951
|
+
raise typer.Exit(1)
|
|
952
|
+
|
|
953
|
+
if install:
|
|
954
|
+
try:
|
|
955
|
+
# Use typer's built-in completion installer
|
|
956
|
+
import typer.completion as _tc
|
|
957
|
+
_tc.install(shell=target)
|
|
958
|
+
console.print(f"[green]Completions installed for {target}.[/green]")
|
|
959
|
+
except Exception:
|
|
960
|
+
console.print(f"[yellow]Auto-install not available. Generate manually:[/yellow]")
|
|
961
|
+
console.print(f" msapling completions {shell_name} >> <your-shell-profile>")
|
|
962
|
+
else:
|
|
963
|
+
try:
|
|
964
|
+
result = _sp.run(
|
|
965
|
+
[sys.executable, "-m", "msapling_cli.main"],
|
|
966
|
+
env={**os.environ, "_MSAPLING_COMPLETE": f"complete_{target}"},
|
|
967
|
+
capture_output=True, text=True,
|
|
968
|
+
)
|
|
969
|
+
if result.stdout:
|
|
970
|
+
print(result.stdout)
|
|
971
|
+
else:
|
|
972
|
+
console.print(f"[dim]Completion script for {target} (pipe to your shell profile).[/dim]")
|
|
973
|
+
except Exception as e:
|
|
974
|
+
console.print(f"[red]{e}[/red]")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@app.command()
|
|
978
|
+
def doctor():
|
|
979
|
+
"""Diagnose installation, auth, and connectivity."""
|
|
980
|
+
import shutil
|
|
981
|
+
|
|
982
|
+
console.print("[bold]MSapling Doctor[/bold]")
|
|
983
|
+
console.print()
|
|
984
|
+
|
|
985
|
+
# Python
|
|
986
|
+
console.print(f" Python: {sys.version.split()[0]}")
|
|
987
|
+
|
|
988
|
+
# Dependencies
|
|
989
|
+
for pkg in ["httpx", "rich", "typer", "pydantic"]:
|
|
990
|
+
try:
|
|
991
|
+
mod = __import__(pkg)
|
|
992
|
+
ver = getattr(mod, "__version__", "?")
|
|
993
|
+
console.print(f" {pkg:12s} {ver} [green]OK[/green]")
|
|
994
|
+
except ImportError:
|
|
995
|
+
console.print(f" {pkg:12s} [red]MISSING[/red]")
|
|
996
|
+
|
|
997
|
+
# Git
|
|
998
|
+
git_path = shutil.which("git")
|
|
999
|
+
console.print(f" git: {'[green]' + git_path + '[/green]' if git_path else '[red]NOT FOUND[/red]'}")
|
|
1000
|
+
|
|
1001
|
+
# ripgrep
|
|
1002
|
+
rg_path = shutil.which("rg")
|
|
1003
|
+
console.print(f" ripgrep: {'[green]' + rg_path + '[/green]' if rg_path else '[yellow]not found (grep fallback)[/yellow]'}")
|
|
1004
|
+
|
|
1005
|
+
# Auth
|
|
1006
|
+
token = get_token()
|
|
1007
|
+
console.print(f" Auth token: {'[green]present[/green]' if token else '[red]missing (run: msapling login)[/red]'}")
|
|
1008
|
+
|
|
1009
|
+
# API connectivity
|
|
1010
|
+
async def _check():
|
|
1011
|
+
client = MSaplingClient()
|
|
1012
|
+
try:
|
|
1013
|
+
ok = await client.health_check()
|
|
1014
|
+
return ok
|
|
1015
|
+
finally:
|
|
1016
|
+
await client.close()
|
|
1017
|
+
|
|
1018
|
+
try:
|
|
1019
|
+
api_ok = _run(_check())
|
|
1020
|
+
console.print(f" API: {'[green]reachable[/green]' if api_ok else '[red]unreachable[/red]'}")
|
|
1021
|
+
except Exception:
|
|
1022
|
+
console.print(f" API: [red]unreachable[/red]")
|
|
1023
|
+
|
|
1024
|
+
# Config
|
|
1025
|
+
settings = get_settings()
|
|
1026
|
+
console.print(f" API URL: {settings.api_url}")
|
|
1027
|
+
console.print(f" Model: {settings.default_model}")
|
|
1028
|
+
console.print()
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@app.command()
|
|
1032
|
+
def version():
|
|
1033
|
+
"""Show version."""
|
|
1034
|
+
console.print(f"msapling-cli v{__version__}")
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
if __name__ == "__main__":
|
|
1038
|
+
app()
|