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/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()