msapling-cli 0.1.3__tar.gz → 0.1.5__tar.gz

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.
Files changed (38) hide show
  1. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/PKG-INFO +4 -1
  2. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/__init__.py +1 -1
  3. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/agent.py +318 -14
  4. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/api.py +16 -8
  5. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/completer.py +5 -2
  6. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/config.py +5 -2
  7. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/main.py +1 -1
  8. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/shell.py +173 -46
  9. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/PKG-INFO +4 -1
  10. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/requires.txt +4 -0
  11. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/pyproject.toml +3 -2
  12. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_agent.py +8 -1
  13. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_api.py +40 -0
  14. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_config.py +1 -1
  15. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_shell.py +18 -1
  16. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/LICENSE +0 -0
  17. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/README.md +0 -0
  18. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/local.py +0 -0
  19. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/mcp/__init__.py +0 -0
  20. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/mcp/server.py +0 -0
  21. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/memory.py +0 -0
  22. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/session.py +0 -0
  23. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/storage.py +0 -0
  24. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/tier.py +0 -0
  25. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/tui.py +0 -0
  26. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli/worker_pool.py +0 -0
  27. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/SOURCES.txt +0 -0
  28. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/dependency_links.txt +0 -0
  29. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/entry_points.txt +0 -0
  30. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/msapling_cli.egg-info/top_level.txt +0 -0
  31. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/setup.cfg +0 -0
  32. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_local.py +0 -0
  33. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_mcp.py +0 -0
  34. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_memory.py +0 -0
  35. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_session.py +0 -0
  36. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_storage.py +0 -0
  37. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_tier.py +0 -0
  38. {msapling_cli-0.1.3 → msapling_cli-0.1.5}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msapling-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: MSapling CLI - Multi-chat AI development environment in your terminal
5
5
  Author: MSapling Team
6
6
  License-Expression: MIT
@@ -31,9 +31,12 @@ Provides-Extra: keyring
31
31
  Requires-Dist: keyring>=24.0; extra == "keyring"
32
32
  Provides-Extra: tui
33
33
  Requires-Dist: textual>=0.40.0; extra == "tui"
34
+ Provides-Extra: pdf
35
+ Requires-Dist: pdfplumber>=0.10.0; extra == "pdf"
34
36
  Provides-Extra: all
35
37
  Requires-Dist: keyring>=24.0; extra == "all"
36
38
  Requires-Dist: textual>=0.40.0; extra == "all"
39
+ Requires-Dist: pdfplumber>=0.10.0; extra == "all"
37
40
  Provides-Extra: dev
38
41
  Requires-Dist: build>=1.2.0; extra == "dev"
39
42
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -1,2 +1,2 @@
1
1
  """MSapling CLI - Multi-chat AI development environment in your terminal."""
2
- __version__ = "0.1.3"
2
+ __version__ = "0.1.5"
@@ -56,7 +56,10 @@ _permission_mode: str = "ask"
56
56
  _permissions: Dict[str, bool] = {}
57
57
 
58
58
  # Read-only tools that never require approval in any mode
59
- _READ_ONLY_TOOLS = frozenset({"read_file", "glob_files", "grep_search", "web_search", "cost_check"})
59
+ _READ_ONLY_TOOLS = frozenset({
60
+ "read_file", "glob_files", "grep_search", "web_search", "web_fetch",
61
+ "cost_check", "list_directory", "ask_user", "todo_list",
62
+ })
60
63
 
61
64
  # Write tools blocked in readonly/plan mode
62
65
  _WRITE_TOOLS = frozenset({"write_file", "edit_file", "run_command"})
@@ -131,6 +134,31 @@ AGENT_TOOLS_SCHEMA = [
131
134
  "description": "Search the web for current information. Uses MSapling's backend search. Use when the user asks about recent events, documentation, or anything that might be outdated in your training data.",
132
135
  "parameters": {"query": "string (search query)"},
133
136
  },
137
+ {
138
+ "name": "web_fetch",
139
+ "description": "Fetch the content of a specific URL. Returns the page text (HTML stripped). Use for reading documentation, API references, or specific web pages.",
140
+ "parameters": {"url": "string (full URL to fetch)"},
141
+ },
142
+ {
143
+ "name": "list_directory",
144
+ "description": "List files and directories at a path with sizes and types. More detailed than glob_files.",
145
+ "parameters": {"path": "string (relative directory path, default '.')"},
146
+ },
147
+ {
148
+ "name": "ask_user",
149
+ "description": "Ask the user a clarifying question when you need more information to proceed. Use sparingly — only when truly ambiguous.",
150
+ "parameters": {"question": "string (the question to ask)"},
151
+ },
152
+ {
153
+ "name": "todo_list",
154
+ "description": "Manage a task checklist for the current session. Use to plan multi-step work and track progress.",
155
+ "parameters": {"action": "string ('add', 'done', 'list', 'clear')", "item": "string (task description, for 'add' and 'done')"},
156
+ },
157
+ {
158
+ "name": "search_replace_all",
159
+ "description": "Replace ALL occurrences of a string in a file (not just the first). Use when renaming a variable or updating all imports.",
160
+ "parameters": {"path": "string (relative file path)", "old_string": "string (text to find)", "new_string": "string (replacement)"},
161
+ },
134
162
  {
135
163
  "name": "cost_check",
136
164
  "description": "Check the user's remaining fuel credits and session cost before expensive operations. Use this before large file writes or multi-step tasks.",
@@ -157,17 +185,22 @@ To use a tool, respond with a JSON block in this format:
157
185
  ```
158
186
 
159
187
  Available tools:
160
- - read_file: Read a file. Args: path, offset (optional), limit (optional)
188
+ - read_file: Read a file (supports text, PDF, notebooks). Args: path, offset (optional), limit (optional)
161
189
  - write_file: Create/overwrite a file. Args: path, content
162
- - edit_file: Replace a specific string in a file. Args: path, old_string, new_string
190
+ - edit_file: Replace first occurrence of a string. Args: path, old_string, new_string
191
+ - search_replace_all: Replace ALL occurrences in a file. Args: path, old_string, new_string
163
192
  - run_command: Execute a shell command. Args: command
193
+ - list_directory: List files with sizes and types. Args: path (default ".")
164
194
  - glob_files: Find files by pattern. Args: pattern
165
195
  - grep_search: Search file contents. Args: pattern, path (optional)
166
196
  - web_search: Search the web via MSapling backend. Args: query
167
- - cost_check: Check remaining fuel credits and session cost. No args.
168
- - model_switch: Switch to a different model mid-task. Args: model (e.g. "google/gemini-2.0-flash-001")
197
+ - web_fetch: Fetch content of a specific URL. Args: url
198
+ - ask_user: Ask the user a clarifying question. Args: question
199
+ - todo_list: Manage task checklist. Args: action (add/done/list/clear), item
200
+ - cost_check: Check remaining fuel credits. No args.
201
+ - model_switch: Switch model mid-task. Args: model
169
202
 
170
- Cost tips: Use cost_check before expensive multi-step tasks. Use model_switch to use cheaper models for boilerplate.
203
+ Tips: Use cost_check before expensive tasks. Use model_switch for cheaper models on grunt work. Use search_replace_all for renaming variables across a file.
171
204
 
172
205
  You can use multiple tools in one response. After tool results, continue reasoning.
173
206
  When you're done (no more tools needed), just respond with your final message.
@@ -356,17 +389,103 @@ def _web_search(query: str) -> str:
356
389
  return f"Web search error: {e}"
357
390
 
358
391
 
392
+ # ─── Command Sandbox ─────────────────────────────────────────────────
393
+ # Block dangerous command patterns. Not OS-level but catches common mistakes.
394
+
395
+ _BLOCKED_COMMAND_PATTERNS = [
396
+ r"\brm\s+(-[rRf]+\s+)?/", # rm -rf /
397
+ r"\brm\s+-[rRf]*\s+~", # rm -rf ~
398
+ r"\brm\s+-[rRf]*\s+\.\.", # rm -rf ..
399
+ r"\bsudo\b", # sudo anything
400
+ r"\bchmod\s+777\b", # chmod 777
401
+ r"\bchmod\s+-R\b", # chmod -R (recursive)
402
+ r"\bmkfs\b", # format filesystem
403
+ r"\bdd\s+if=", # dd disk write
404
+ r"\b:()\s*\{", # fork bomb
405
+ r"\bcurl\b.*\|\s*bash", # curl | bash
406
+ r"\bwget\b.*\|\s*bash", # wget | bash
407
+ r"\bcurl\b.*\|\s*sh", # curl | sh
408
+ r"\bwget\b.*\|\s*sh", # wget | sh
409
+ r">\s*/dev/sd", # write to disk device
410
+ r"\bshutdown\b", # shutdown
411
+ r"\breboot\b", # reboot
412
+ r"\bkill\s+-9\s+1\b", # kill init
413
+ r"\biptables\b", # firewall rules
414
+ r"\bufw\b", # firewall
415
+ ]
416
+
417
+ _sandbox_enabled: bool = True
418
+
419
+
420
+ def _check_command_sandbox(command: str) -> Optional[str]:
421
+ """Check if a command is blocked by the sandbox. Returns error message or None."""
422
+ if not _sandbox_enabled:
423
+ return None
424
+ cmd_lower = command.lower().strip()
425
+ for pattern in _BLOCKED_COMMAND_PATTERNS:
426
+ if re.search(pattern, cmd_lower):
427
+ return f"Blocked by sandbox: '{command[:60]}' matches dangerous pattern. Use /sandbox off to disable."
428
+ return None
429
+
430
+
431
+ def set_sandbox(enabled: bool) -> bool:
432
+ global _sandbox_enabled
433
+ _sandbox_enabled = enabled
434
+ return _sandbox_enabled
435
+
436
+
437
+ # Strong references to background tasks so they don't get GC'd mid-flight.
438
+ # See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
439
+ _background_tasks: set = set()
440
+
441
+ # Agent todo list (managed via todo_list tool)
442
+ _agent_todos: List[Dict[str, Any]] = []
443
+
444
+
445
+ def _mdrive_auto_push(project_root: str, rel_path: str, content: str, project_name: str = "", agent_id: str = "cli-agent"):
446
+ """Background push to MDrive after local file write. Best-effort, non-blocking."""
447
+ if not project_name:
448
+ return
449
+ try:
450
+ import asyncio as _aio
451
+ from .api import MSaplingClient
452
+
453
+ async def _push():
454
+ client = MSaplingClient()
455
+ try:
456
+ await client.mdrive_write(
457
+ project_name, rel_path, content,
458
+ agent_id=agent_id,
459
+ change_summary="Auto-pushed by agent tool",
460
+ )
461
+ finally:
462
+ await client.close()
463
+
464
+ try:
465
+ loop = _aio.get_running_loop()
466
+ task = loop.create_task(_push())
467
+ # Hold strong reference until task completes
468
+ _background_tasks.add(task)
469
+ task.add_done_callback(_background_tasks.discard)
470
+ except RuntimeError:
471
+ _aio.run(_push())
472
+ except Exception:
473
+ pass # Best-effort, never block the agent
474
+
475
+
359
476
  def _execute_tool(
360
477
  tool_name: str,
361
478
  args: Dict[str, Any],
362
479
  project_root: str,
363
480
  hooks_mgr: Any = None,
364
481
  model: str = "",
482
+ project_name: str = "",
365
483
  ) -> str:
366
484
  """Execute a single tool call locally. Returns result as string.
367
485
 
368
486
  When *hooks_mgr* is provided (a HooksManager), the function fires
369
487
  *on_file_write* hooks after a successful write_file or edit_file.
488
+ When *project_name* is set, auto-pushes written files to MDrive.
370
489
  """
371
490
 
372
491
  # --- Approval gate for mutating tools ---
@@ -382,7 +501,47 @@ def _execute_tool(
382
501
  if not fpath.is_file():
383
502
  return f"Error: file not found: {path}"
384
503
  try:
385
- content = fpath.read_text(encoding="utf-8", errors="ignore")
504
+ ext = fpath.suffix.lower()
505
+
506
+ # PDF reading
507
+ if ext == ".pdf":
508
+ try:
509
+ import pdfplumber
510
+ text_parts = []
511
+ with pdfplumber.open(fpath) as pdf:
512
+ for i, page in enumerate(pdf.pages[:30]):
513
+ page_text = page.extract_text() or ""
514
+ if page_text.strip():
515
+ text_parts.append(f"--- Page {i+1} ---\n{page_text}")
516
+ content = "\n\n".join(text_parts) if text_parts else "(no extractable text)"
517
+ except ImportError:
518
+ return "Error: PDF reading requires pdfplumber. Install: pip install pdfplumber"
519
+
520
+ # Jupyter notebook reading
521
+ elif ext == ".ipynb":
522
+ try:
523
+ nb = json.loads(fpath.read_text(encoding="utf-8"))
524
+ cells = nb.get("cells", [])
525
+ parts = []
526
+ for i, cell in enumerate(cells):
527
+ cell_type = cell.get("cell_type", "code")
528
+ source = "".join(cell.get("source", []))
529
+ if cell_type == "markdown":
530
+ parts.append(f"[Markdown cell {i+1}]\n{source}")
531
+ else:
532
+ parts.append(f"[Code cell {i+1}]\n```\n{source}\n```")
533
+ outputs = cell.get("outputs", [])
534
+ for out in outputs[:3]:
535
+ if "text" in out:
536
+ parts.append("Output: " + "".join(out["text"])[:500])
537
+ content = "\n\n".join(parts)
538
+ except Exception as e:
539
+ return f"Error parsing notebook: {e}"
540
+
541
+ # Standard text file
542
+ else:
543
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
544
+
386
545
  offset = int(args.get("offset", 0))
387
546
  limit = int(args.get("limit", 0))
388
547
  if offset or limit:
@@ -411,6 +570,8 @@ def _execute_tool(
411
570
  project=project_root, model=model,
412
571
  file=str(fpath), tool="write_file",
413
572
  )
573
+ # Auto-push to MDrive (best-effort, non-blocking)
574
+ _mdrive_auto_push(project_root, path, content, project_name)
414
575
  return f"Wrote {len(content)} bytes to {path}"
415
576
  except Exception as e:
416
577
  return f"Error writing {path}: {e}"
@@ -431,7 +592,9 @@ def _execute_tool(
431
592
  # Backup before editing (like Claude Code's file-history)
432
593
  try:
433
594
  from .storage import backup_file
434
- backup_file(path, content, "agent")
595
+ import hashlib
596
+ session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
597
+ backup_file(path, content, session_key)
435
598
  except Exception:
436
599
  pass
437
600
  new_content = content.replace(old_string, new_string, 1)
@@ -442,6 +605,8 @@ def _execute_tool(
442
605
  project=project_root, model=model,
443
606
  file=str(fpath), tool="edit_file",
444
607
  )
608
+ # Auto-push to MDrive (best-effort, non-blocking)
609
+ _mdrive_auto_push(project_root, path, new_content, project_name)
445
610
  return f"Edited {path}: replaced {len(old_string)} chars with {len(new_string)} chars"
446
611
  except Exception as e:
447
612
  return f"Error editing {path}: {e}"
@@ -450,10 +615,14 @@ def _execute_tool(
450
615
  command = args.get("command", "")
451
616
  if not command:
452
617
  return "Error: no command provided"
618
+ # Sandbox check: block dangerous patterns
619
+ blocked = _check_command_sandbox(command)
620
+ if blocked:
621
+ return blocked
453
622
  try:
454
623
  result = subprocess.run(
455
624
  command, shell=True, cwd=project_root,
456
- capture_output=True, text=True, timeout=30,
625
+ capture_output=True, text=True, timeout=60,
457
626
  )
458
627
  output = ""
459
628
  if result.stdout:
@@ -463,7 +632,7 @@ def _execute_tool(
463
632
  output += f"\n(exit code: {result.returncode})"
464
633
  return output or "(no output)"
465
634
  except subprocess.TimeoutExpired:
466
- return "Error: command timed out (30s limit)"
635
+ return "Error: command timed out (60s limit)"
467
636
  except Exception as e:
468
637
  return f"Error running command: {e}"
469
638
 
@@ -511,11 +680,138 @@ def _execute_tool(
511
680
  return "Error: no search query provided"
512
681
  return _web_search(query)
513
682
 
514
- elif tool_name == "cost_check":
683
+ elif tool_name == "web_fetch":
684
+ url = args.get("url", "")
685
+ if not url:
686
+ return "Error: no URL provided"
515
687
  try:
688
+ # Try backend first, fall back to direct fetch
516
689
  from .config import get_settings, get_token
690
+ import httpx as _httpx
691
+ settings = get_settings()
692
+ api_url = settings.api_url.rstrip("/")
693
+ token = get_token()
694
+ cookies = {"msaplingauth": token} if token else {}
695
+ try:
696
+ resp = _httpx.post(
697
+ f"{api_url}/api/tools/web-research",
698
+ json={"url": url}, cookies=cookies, timeout=15.0,
699
+ )
700
+ if resp.status_code == 200:
701
+ content = resp.json().get("content", resp.json().get("text", ""))
702
+ if content and len(content) > 20:
703
+ return content[:8000]
704
+ except Exception:
705
+ pass
706
+ # Direct fetch fallback
707
+ resp = _httpx.get(url, timeout=15.0, follow_redirects=True,
708
+ headers={"User-Agent": "MSapling-CLI/0.2"})
709
+ text = resp.text
710
+ # Strip HTML tags for readability
711
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
712
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
713
+ text = re.sub(r"<[^>]+>", " ", text)
714
+ text = re.sub(r"\s+", " ", text).strip()
715
+ return text[:8000] if text else "No content found"
716
+ except Exception as e:
717
+ return f"Error fetching URL: {e}"
718
+
719
+ elif tool_name == "list_directory":
720
+ dir_path = args.get("path", ".")
721
+ fpath = _safe_path(project_root, dir_path)
722
+ if not fpath:
723
+ return f"Error: path '{dir_path}' is outside project root"
724
+ if not fpath.is_dir():
725
+ return f"Error: not a directory: {dir_path}"
726
+ try:
727
+ skip = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
728
+ entries = []
729
+ for item in sorted(fpath.iterdir()):
730
+ name = item.name
731
+ if name in skip:
732
+ continue
733
+ if item.is_dir():
734
+ entries.append(f" {name}/")
735
+ else:
736
+ size = item.stat().st_size
737
+ size_str = f"{size / 1024:.1f}KB" if size > 1024 else f"{size}B"
738
+ entries.append(f" {name} ({size_str})")
739
+ if len(entries) >= 80:
740
+ entries.append(f" ... (truncated)")
741
+ break
742
+ return "\n".join(entries) if entries else "(empty directory)"
743
+ except Exception as e:
744
+ return f"Error: {e}"
745
+
746
+ elif tool_name == "ask_user":
747
+ question = args.get("question", "")
748
+ if not question:
749
+ return "Error: no question provided"
750
+ try:
751
+ console.print(f"\n[bold yellow]Agent asks:[/bold yellow] {question}")
752
+ answer = console.input("[bold green]Your answer:[/bold green] ")
753
+ return answer.strip() or "(no answer)"
754
+ except (KeyboardInterrupt, EOFError):
755
+ return "(user declined to answer)"
756
+
757
+ elif tool_name == "todo_list":
758
+ action = args.get("action", "list").lower()
759
+ item = args.get("item", "")
760
+ # Module-level todo storage
761
+ global _agent_todos
762
+ if action == "add" and item:
763
+ _agent_todos.append({"text": item, "done": False})
764
+ return f"Added: {item} ({len(_agent_todos)} total)"
765
+ elif action == "done" and item:
766
+ for t in _agent_todos:
767
+ if item.lower() in t["text"].lower() and not t["done"]:
768
+ t["done"] = True
769
+ return f"Completed: {t['text']}"
770
+ return f"Not found: {item}"
771
+ elif action == "clear":
772
+ _agent_todos.clear()
773
+ return "Todo list cleared"
774
+ else:
775
+ if not _agent_todos:
776
+ return "Todo list is empty"
777
+ lines = []
778
+ for i, t in enumerate(_agent_todos, 1):
779
+ mark = "x" if t["done"] else " "
780
+ lines.append(f" [{mark}] {i}. {t['text']}")
781
+ return "\n".join(lines)
782
+
783
+ elif tool_name == "search_replace_all":
784
+ path = args.get("path", "")
785
+ old_string = args.get("old_string", "")
786
+ new_string = args.get("new_string", "")
787
+ fpath = _safe_path(project_root, path)
788
+ if not fpath:
789
+ return f"Error: path '{path}' is outside project root"
790
+ if not fpath.is_file():
791
+ return f"Error: file not found: {path}"
792
+ try:
793
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
794
+ count = content.count(old_string)
795
+ if count == 0:
796
+ return f"Error: string not found in {path}"
797
+ try:
798
+ from .storage import backup_file
799
+ import hashlib
800
+ session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
801
+ backup_file(path, content, session_key)
802
+ except Exception:
803
+ pass
804
+ new_content = content.replace(old_string, new_string)
805
+ fpath.write_text(new_content, encoding="utf-8")
806
+ _mdrive_auto_push(project_root, path, new_content, project_name)
807
+ return f"Replaced {count} occurrence(s) in {path}"
808
+ except Exception as e:
809
+ return f"Error: {e}"
810
+
811
+ elif tool_name == "cost_check":
812
+ try:
517
813
  from .api import MSaplingClient
518
- import asyncio as _asyncio
814
+ import concurrent.futures
519
815
 
520
816
  async def _check():
521
817
  client = MSaplingClient()
@@ -531,7 +827,11 @@ def _execute_tool(
531
827
  finally:
532
828
  await client.close()
533
829
 
534
- return _asyncio.get_event_loop().run_until_complete(_check())
830
+ # _execute_tool is sync but called from within a running event loop.
831
+ # Use a thread to avoid "event loop already running" RuntimeError.
832
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
833
+ result = pool.submit(asyncio.run, _check()).result(timeout=10)
834
+ return result
535
835
  except Exception as e:
536
836
  return f"Cost check error: {e}"
537
837
 
@@ -566,6 +866,8 @@ async def run_agent_loop(
566
866
  on_text: Any = None,
567
867
  on_usage: Any = None,
568
868
  hooks_mgr: Any = None,
869
+ project_name: str = "",
870
+ chat_id: str = "",
569
871
  ) -> str:
570
872
  """Run the agentic tool-use loop.
571
873
 
@@ -600,9 +902,10 @@ async def run_agent_loop(
600
902
  # Get LLM response
601
903
  parts = []
602
904
  async for chunk in client.stream_chat(
603
- chat_id=str(uuid.uuid4())[:12],
905
+ chat_id=chat_id or str(uuid.uuid4())[:12],
604
906
  prompt=prompt if step == 0 else "",
605
907
  model=model,
908
+ project_name=project_name,
606
909
  history=messages[-30:], # Keep recent context
607
910
  ):
608
911
  content = chunk.get("content", "")
@@ -648,6 +951,7 @@ async def run_agent_loop(
648
951
  result = _execute_tool(
649
952
  tool_name, tool_args, project_root,
650
953
  hooks_mgr=hooks_mgr, model=model,
954
+ project_name=project_name,
651
955
  )
652
956
 
653
957
  # on_after_tool hook
@@ -19,6 +19,14 @@ PAID_DEFAULT_CHAT_LIMIT = 10
19
19
  FREE_DEFAULT_CHAT_LIMIT = 5
20
20
 
21
21
 
22
+ def _mdrive_scoped_path(project_id: str, file_path: str = "") -> str:
23
+ project = str(project_id or "").strip().strip("/")
24
+ rel = str(file_path or "").strip().strip("/")
25
+ if project and rel:
26
+ return f"{project}/{rel}"
27
+ return project or rel or "."
28
+
29
+
22
30
  def _is_paid_user(user: Optional[Dict[str, Any]]) -> bool:
23
31
  tier = str((user or {}).get("tier", "free")).lower()
24
32
  return bool((user or {}).get("is_pro", False)) or tier in ("lifetime", "monthly", "pro", "enterprise")
@@ -303,7 +311,7 @@ class MSaplingClient:
303
311
  async def list_project_chats(self, project_name: str) -> List[Dict[str, Any]]:
304
312
  """Get chats for a specific project."""
305
313
  client = await self._get_client()
306
- resp = await _retry_request(lambda: client.get("/api/projects", params={
314
+ resp = await _retry_request(lambda: client.get("/api/projects/", params={
307
315
  "limit": 100,
308
316
  }))
309
317
  resp.raise_for_status()
@@ -416,14 +424,16 @@ class MSaplingClient:
416
424
 
417
425
  async def list_files(self, project_id: str, path: str = "/") -> list:
418
426
  client = await self._get_client()
419
- resp = await _retry_request(lambda: client.get("/api/mdrive/list", params={"project_id": project_id, "path": path}))
427
+ scoped_path = _mdrive_scoped_path(project_id, "" if path in ("", "/", ".") else path)
428
+ resp = await _retry_request(lambda: client.get("/api/mdrive/list", params={"path": scoped_path}))
420
429
  resp.raise_for_status()
421
430
  data = resp.json()
422
431
  return data if isinstance(data, list) else data.get("files", [])
423
432
 
424
433
  async def mdrive_read(self, project_id: str, file_path: str) -> str:
425
434
  client = await self._get_client()
426
- resp = await _retry_request(lambda: client.post("/api/mdrive/read", json={"project_id": project_id, "file_path": file_path}))
435
+ payload = {"path": _mdrive_scoped_path(project_id, file_path)}
436
+ resp = await _retry_request(lambda: client.post("/api/mdrive/read", json=payload))
427
437
  resp.raise_for_status()
428
438
  return resp.json().get("content", "")
429
439
 
@@ -437,8 +447,7 @@ class MSaplingClient:
437
447
  ) -> Dict[str, Any]:
438
448
  client = await self._get_client()
439
449
  payload: Dict[str, Any] = {
440
- "project_id": project_id,
441
- "file_path": file_path,
450
+ "path": _mdrive_scoped_path(project_id, file_path),
442
451
  "content": content,
443
452
  }
444
453
  if agent_id:
@@ -455,9 +464,8 @@ class MSaplingClient:
455
464
 
456
465
  async def mdrive_delete(self, project_id: str, file_path: str) -> Dict[str, Any]:
457
466
  client = await self._get_client()
458
- resp = await _retry_request(lambda: client.post(
459
- f"/api/mdrive/delete/{project_id}/{file_path}",
460
- ))
467
+ payload = {"path": _mdrive_scoped_path(project_id, file_path)}
468
+ resp = await _retry_request(lambda: client.post("/api/mdrive/delete", json=payload))
461
469
  resp.raise_for_status()
462
470
  return resp.json()
463
471
 
@@ -71,6 +71,8 @@ SLASH_COMMANDS: List[str] = [
71
71
  "/export",
72
72
  "/restore",
73
73
  "/permissions",
74
+ "/sandbox",
75
+ "/worktree",
74
76
  ]
75
77
 
76
78
  # Commands after which Tab should complete file paths
@@ -81,7 +83,7 @@ _SKIP_DIRS = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "b
81
83
 
82
84
  # Well-known model IDs for offline completion (supplemented at runtime)
83
85
  DEFAULT_MODELS: List[str] = [
84
- "google/gemini-flash-1.5",
86
+ "google/gemini-2.0-flash-001",
85
87
  "google/gemini-pro-1.5",
86
88
  "google/gemini-2.0-flash",
87
89
  "anthropic/claude-3-haiku",
@@ -322,6 +324,7 @@ MODEL_ALIASES: dict[str, str] = {
322
324
  "gemini flash": "google/gemini-2.0-flash-001",
323
325
  "gemini 2.0 flash": "google/gemini-2.0-flash-001",
324
326
  "gemini 2 flash": "google/gemini-2.0-flash-001",
327
+ "gemini 2.5 flash": "google/gemini-2.5-flash-preview-05-20",
325
328
  "gemini flash 1.5": "google/gemini-flash-1.5",
326
329
  "gemini 1.5 flash": "google/gemini-flash-1.5",
327
330
  "gemini pro": "google/gemini-2.0-pro-exp-02-05",
@@ -376,7 +379,7 @@ def resolve_model(user_input: str, available_models: Optional[List[str]] = None)
376
379
  """
377
380
  raw = user_input.strip()
378
381
  if not raw:
379
- return "google/gemini-flash-1.5", True
382
+ return "google/gemini-2.0-flash-001", True
380
383
 
381
384
  # 1. Exact match (user typed a full ID like "google/gemini-2.0-flash-001")
382
385
  if "/" in raw:
@@ -16,7 +16,7 @@ _TOKEN_FILE = _CONFIG_DIR / "token"
16
16
 
17
17
  class Settings(BaseSettings):
18
18
  api_url: str = "https://api.msapling.com"
19
- default_model: str = "google/gemini-flash-1.5"
19
+ default_model: str = "google/gemini-2.0-flash-001"
20
20
  max_tokens: int = 4096
21
21
  temperature: float = 0.7
22
22
  theme: str = "dark"
@@ -48,7 +48,10 @@ def get_token() -> Optional[str]:
48
48
  def save_token(token: str) -> None:
49
49
  _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
50
50
  _TOKEN_FILE.write_text(token)
51
- _TOKEN_FILE.chmod(0o600)
51
+ try:
52
+ _TOKEN_FILE.chmod(0o600)
53
+ except OSError:
54
+ pass # Windows doesn't support Unix permissions
52
55
 
53
56
 
54
57
  def clear_token() -> None:
@@ -301,7 +301,7 @@ def chat(
301
301
  client = MSaplingClient()
302
302
  try:
303
303
  parts = []
304
- with Live(console=console, refresh_per_second=15) as live:
304
+ with Live(console=console, refresh_per_second=8) as live:
305
305
  async for chunk in client.stream_chat(
306
306
  chat_id=chat_id,
307
307
  prompt=msg,
@@ -146,6 +146,29 @@ SLASH_HELP = """
146
146
  """.strip()
147
147
 
148
148
  # ─── Hooks System ─────────────────────────────────────────────────────
149
+ # Supported events (matching Claude Code's hook model):
150
+ # on_session_start — Shell starts (after auth, before input loop)
151
+ # on_session_end — Shell exits (before save)
152
+ # on_before_message — Before sending user message to LLM
153
+ # on_after_message — After receiving LLM response
154
+ # on_before_tool — Before agent executes a tool
155
+ # on_after_tool — After agent executes a tool
156
+ # on_tool_failure — When a tool execution fails
157
+ # on_file_write — After a file is written (by agent or /write)
158
+ # on_file_read — After a file is read into context
159
+ # on_permission_ask — When user is prompted for tool approval
160
+ # on_model_change — When model is switched (/model or agent model_switch)
161
+ # on_compact — When context is compacted (/compact)
162
+ # on_worktree_create — When a git worktree is created
163
+ # on_worktree_remove — When a git worktree is removed
164
+ # on_subagent_start — When a background subagent is spawned
165
+ # on_subagent_done — When a subagent completes
166
+ # on_worker_spawn — When a multi-chat worker is created
167
+ # on_worker_kill — When a worker is killed
168
+ # on_instructions_loaded — When project instructions (.msapling.md) are loaded
169
+ # on_budget_exceeded — When cost ceiling is hit
170
+ # on_config_change — When a setting is changed
171
+ # on_cwd_change — When project root / worktree changes
149
172
 
150
173
  HOOKS_FILE = Path.home() / ".msapling" / "hooks.json"
151
174
 
@@ -177,6 +200,21 @@ def _safe_path(project_root: str, rel_path: str) -> Optional[Path]:
177
200
  return None
178
201
 
179
202
 
203
+ def _parse_menu_number(choice: str, action: str, *, allow_bare_number: bool = False) -> Optional[int]:
204
+ """Parse menu actions like `R 1`, `R1`, `R<1>`, or bare `1`."""
205
+ cleaned = str(choice or "").strip().upper()
206
+ if not cleaned:
207
+ return None
208
+ if allow_bare_number and cleaned.isdigit():
209
+ value = int(cleaned)
210
+ return value if value > 0 else None
211
+ match = re.fullmatch(rf"{re.escape(action.upper())}\s*<?\s*(\d+)\s*>?", cleaned)
212
+ if not match:
213
+ return None
214
+ value = int(match.group(1))
215
+ return value if value > 0 else None
216
+
217
+
180
218
  def _run_hooks(event: str, hooks: Dict[str, List[str]], cwd: str) -> List[str]:
181
219
  """Run hooks for a lifecycle event. Returns list of outputs."""
182
220
  outputs = []
@@ -271,6 +309,7 @@ class InteractiveShell:
271
309
  instructions = _load_project_instructions(self.project_root)
272
310
  if instructions:
273
311
  self.messages.append({"role": "system", "content": instructions})
312
+ _run_hooks("on_instructions_loaded", self.hooks, self.project_root)
274
313
 
275
314
  memories = get_memories(self.project_root)
276
315
  if memories:
@@ -303,6 +342,8 @@ class InteractiveShell:
303
342
  console.print(f"[dim][hook] {out}[/dim]")
304
343
 
305
344
  await self._main_loop()
345
+ for out in _run_hooks("on_session_end", self.hooks, self.project_root):
346
+ console.print(f"[dim][hook] {out}[/dim]")
306
347
  self._save()
307
348
  unregister_session(os.getpid())
308
349
  console.print("[ms.dim]Session saved. Resume with: msapling shell --resume[/ms.dim]")
@@ -422,23 +463,20 @@ class InteractiveShell:
422
463
  return False
423
464
 
424
465
  # Resume existing chat
425
- if choice.startswith("R"):
426
- parts = choice.split()
427
- if len(parts) < 2 or not chats:
428
- console.print(" [yellow]Usage: R <number>[/yellow]")
429
- continue
430
- try:
431
- idx = int(parts[1]) - 1
432
- if 0 <= idx < len(chats):
433
- c = chats[idx]
434
- self.chat_id = c.get("id")
435
- self.model = c.get("model", self.model)
436
- console.print(f" [green]Resumed: {c.get('title', 'Chat')}[/green]")
437
- return True
438
- except (ValueError, IndexError):
439
- pass
466
+ resume_number = _parse_menu_number(choice, "R", allow_bare_number=True)
467
+ if resume_number is not None and chats:
468
+ idx = resume_number - 1
469
+ if 0 <= idx < len(chats):
470
+ c = chats[idx]
471
+ self.chat_id = c.get("id")
472
+ self.model = c.get("model", self.model)
473
+ console.print(f" [green]Resumed: {c.get('title', 'Chat')}[/green]")
474
+ return True
440
475
  console.print(" [yellow]Invalid chat number.[/yellow]")
441
476
  continue
477
+ if choice == "R" or (choice.startswith("R") and chats):
478
+ console.print(" [yellow]Usage: R <number>[/yellow]")
479
+ continue
442
480
 
443
481
  # New single chat
444
482
  if choice == "S":
@@ -458,18 +496,15 @@ class InteractiveShell:
458
496
  continue
459
497
 
460
498
  # Multi-chat workers
461
- if choice.startswith("M"):
499
+ multi_number = _parse_menu_number(choice, "M")
500
+ if choice == "M" or multi_number is not None:
462
501
  if not is_paid:
463
502
  console.print(" [yellow]Multi-chat requires Pro tier.[/yellow]")
464
503
  continue
465
- parts = choice.split()
466
- count = 2
467
- if len(parts) >= 2:
468
- try:
469
- count = int(parts[1])
470
- except ValueError:
471
- console.print(" [yellow]Usage: M <number>[/yellow]")
472
- continue
504
+ count = multi_number if multi_number is not None else 2
505
+ if choice.startswith("M") and multi_number is None and choice != "M":
506
+ console.print(" [yellow]Usage: M <number>[/yellow]")
507
+ continue
473
508
  if count < 2:
474
509
  count = 2
475
510
  if count > free_slots:
@@ -482,33 +517,31 @@ class InteractiveShell:
482
517
  await self._setup_workers(count)
483
518
  if self.workers:
484
519
  self.chat_id = self.workers[0]["chat_id"]
520
+ self.model = self.workers[0]["model"]
485
521
  return True
486
522
  continue
487
523
 
488
524
  # Delete chat
489
- if choice.startswith("D"):
490
- parts = choice.split()
491
- if len(parts) < 2 or not chats:
492
- console.print(" [yellow]Usage: D <number>[/yellow]")
493
- continue
494
- try:
495
- idx = int(parts[1]) - 1
496
- if 0 <= idx < len(chats):
497
- c = chats[idx]
498
- try:
499
- await self.client.delete_chat(c["id"])
500
- title = c.get("title", "Chat")
501
- console.print(f" [green]Deleted: {title}[/green]")
502
- chats.pop(idx)
503
- chat_count -= 1
504
- free_slots += 1
505
- except Exception as e:
506
- console.print(f" [red]{e}[/red]")
507
- else:
508
- console.print(" [yellow]Invalid chat number.[/yellow]")
509
- except (ValueError, IndexError):
525
+ delete_number = _parse_menu_number(choice, "D")
526
+ if delete_number is not None and chats:
527
+ idx = delete_number - 1
528
+ if 0 <= idx < len(chats):
529
+ c = chats[idx]
530
+ try:
531
+ await self.client.delete_chat(c["id"])
532
+ title = c.get("title", "Chat")
533
+ console.print(f" [green]Deleted: {title}[/green]")
534
+ chats.pop(idx)
535
+ chat_count -= 1
536
+ free_slots += 1
537
+ except Exception as e:
538
+ console.print(f" [red]{e}[/red]")
539
+ else:
510
540
  console.print(" [yellow]Invalid chat number.[/yellow]")
511
541
  continue
542
+ if choice == "D" or (choice.startswith("D") and chats):
543
+ console.print(" [yellow]Usage: D <number>[/yellow]")
544
+ continue
512
545
 
513
546
  console.print(" [yellow]Invalid choice.[/yellow]")
514
547
 
@@ -624,6 +657,7 @@ class InteractiveShell:
624
657
  return True
625
658
  if self._total_session_cost() >= self.cost_ceiling:
626
659
  console.print(f"[red]Cost ceiling reached (${self.cost_ceiling:.4f}). Use /budget to adjust.[/red]")
660
+ _run_hooks("on_budget_exceeded", self.hooks, self.project_root)
627
661
  return False
628
662
  return True
629
663
 
@@ -677,11 +711,13 @@ class InteractiveShell:
677
711
  on_text=lambda text: None,
678
712
  on_usage=_on_usage,
679
713
  project_name=self.project_name or "",
714
+ chat_id=self.chat_id or "",
680
715
  )
681
716
  if response:
682
717
  render_assistant_response(response)
683
718
  append_conversation(self.project_root, self.session_id, "assistant", response)
684
719
  render_cost_footer(self.tokens_total, self.cost_total)
720
+ _run_hooks("on_after_message", self.hooks, self.project_root)
685
721
 
686
722
  # Check if agent switched models mid-task
687
723
  new_model = get_and_clear_switched_model()
@@ -708,6 +744,8 @@ class InteractiveShell:
708
744
 
709
745
  try:
710
746
  parts = []
747
+ thinking_parts = []
748
+ in_thinking = False
711
749
  with Live(console=console, refresh_per_second=8) as live:
712
750
  async for chunk in self.client.stream_chat(
713
751
  chat_id=self.chat_id or self.session_id,
@@ -717,14 +755,38 @@ class InteractiveShell:
717
755
  history=history_to_send,
718
756
  ):
719
757
  content = chunk.get("content", "")
758
+ # Detect thinking/reasoning blocks (Claude/GPT extended thinking)
759
+ chunk_type = chunk.get("type", "")
760
+ if chunk_type == "thinking" or chunk.get("thinking"):
761
+ thinking_text = chunk.get("thinking", content)
762
+ if thinking_text:
763
+ thinking_parts.append(thinking_text)
764
+ # Show thinking in dim text
765
+ from rich.text import Text
766
+ thinking_display = Text("".join(thinking_parts), style="dim italic")
767
+ live.update(thinking_display)
768
+ continue
720
769
  if content:
770
+ # If we were showing thinking, clear and switch to main response
771
+ if thinking_parts and not parts:
772
+ console.print() # Newline after thinking
721
773
  parts.append(content)
722
774
  live.update(Markdown("".join(parts)))
723
- if chunk.get("type") == "usage":
775
+ if chunk_type == "usage" or chunk.get("usage"):
724
776
  usage = chunk.get("usage", {})
725
777
  self.tokens_total += usage.get("total_tokens", 0)
726
778
  self.cost_total += float(chunk.get("cost", 0))
727
779
 
780
+ # Show thinking summary if any
781
+ if thinking_parts:
782
+ thinking_text = "".join(thinking_parts)
783
+ if len(thinking_text) > 200:
784
+ console.print(Panel(
785
+ f"[dim italic]{thinking_text[:500]}{'...' if len(thinking_text) > 500 else ''}[/dim italic]",
786
+ title="[dim]Thinking[/dim]",
787
+ border_style="dim",
788
+ ))
789
+
728
790
  response = "".join(parts)
729
791
  if response:
730
792
  self.messages.append({"role": "assistant", "content": response})
@@ -755,6 +817,7 @@ class InteractiveShell:
755
817
  console.print(f"[ms.accent]Model: {old} -> {self.model}[/ms.accent] [ms.dim](matched from '{args.strip()}')[/ms.dim]")
756
818
  else:
757
819
  console.print(f"[ms.accent]Model: {old} -> {self.model}[/ms.accent]")
820
+ _run_hooks("on_model_change", self.hooks, self.project_root)
758
821
  else:
759
822
  console.print(f"Current model: [ms.accent]{self.model}[/ms.accent]")
760
823
  elif command == "/models":
@@ -980,6 +1043,70 @@ class InteractiveShell:
980
1043
  else:
981
1044
  new_mode = set_permission_mode(args.strip().lower())
982
1045
  console.print(f"[green]Permission mode: {new_mode}[/green]")
1046
+ elif command == "/worktree":
1047
+ sub = args.split()[0].lower() if args.strip() else "list"
1048
+ wt_arg = args.split()[1] if len(args.split()) > 1 else ""
1049
+ if sub == "list":
1050
+ try:
1051
+ r = subprocess.run(["git", "worktree", "list"], cwd=self.project_root,
1052
+ capture_output=True, text=True, timeout=10)
1053
+ console.print(r.stdout or "[yellow]No worktrees[/yellow]")
1054
+ except Exception as e:
1055
+ console.print(f"[red]{e}[/red]")
1056
+ elif sub == "create" and wt_arg:
1057
+ branch = wt_arg
1058
+ wt_path = os.path.join(self.project_root, f"../{Path(self.project_root).name}-{branch}")
1059
+ try:
1060
+ r = subprocess.run(
1061
+ ["git", "worktree", "add", wt_path, "-b", branch],
1062
+ cwd=self.project_root, capture_output=True, text=True, timeout=30,
1063
+ )
1064
+ if r.returncode == 0:
1065
+ console.print(f"[green]Worktree created: {wt_path}[/green]")
1066
+ console.print(f"[dim]Switch with: /worktree switch {branch}[/dim]")
1067
+ else:
1068
+ console.print(f"[red]{r.stderr}[/red]")
1069
+ except Exception as e:
1070
+ console.print(f"[red]{e}[/red]")
1071
+ elif sub == "switch" and wt_arg:
1072
+ try:
1073
+ r = subprocess.run(["git", "worktree", "list", "--porcelain"],
1074
+ cwd=self.project_root, capture_output=True, text=True, timeout=10)
1075
+ for line in r.stdout.split("\n"):
1076
+ if line.startswith("worktree ") and wt_arg in line:
1077
+ new_root = line.replace("worktree ", "").strip()
1078
+ self.project_root = new_root
1079
+ console.print(f"[green]Switched to worktree: {new_root}[/green]")
1080
+ break
1081
+ else:
1082
+ console.print(f"[yellow]Worktree not found: {wt_arg}[/yellow]")
1083
+ except Exception as e:
1084
+ console.print(f"[red]{e}[/red]")
1085
+ elif sub == "delete" and wt_arg:
1086
+ try:
1087
+ r = subprocess.run(["git", "worktree", "remove", wt_arg],
1088
+ cwd=self.project_root, capture_output=True, text=True, timeout=10)
1089
+ if r.returncode == 0:
1090
+ console.print(f"[green]Worktree removed: {wt_arg}[/green]")
1091
+ else:
1092
+ console.print(f"[red]{r.stderr}[/red]")
1093
+ except Exception as e:
1094
+ console.print(f"[red]{e}[/red]")
1095
+ else:
1096
+ console.print("[yellow]Usage: /worktree <create|list|switch|delete> [branch][/yellow]")
1097
+ elif command == "/sandbox":
1098
+ from .agent import set_sandbox, _sandbox_enabled
1099
+ if not args.strip():
1100
+ status = "[green]ON[/green]" if _sandbox_enabled else "[red]OFF[/red]"
1101
+ console.print(f"[cyan]Command sandbox:[/cyan] {status}")
1102
+ console.print("[dim] Blocks dangerous commands (rm -rf /, sudo, curl|bash, etc.)")
1103
+ console.print(" /sandbox off — disable | /sandbox on — enable[/dim]")
1104
+ elif args.strip().lower() in ("on", "true", "1"):
1105
+ set_sandbox(True)
1106
+ console.print("[green]Sandbox: ON[/green]")
1107
+ elif args.strip().lower() in ("off", "false", "0"):
1108
+ set_sandbox(False)
1109
+ console.print("[yellow]Sandbox: OFF — dangerous commands allowed[/yellow]")
983
1110
  elif command == "/simple":
984
1111
  self.agent_mode = False
985
1112
  console.print("Mode: [yellow]Simple[/yellow]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msapling-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: MSapling CLI - Multi-chat AI development environment in your terminal
5
5
  Author: MSapling Team
6
6
  License-Expression: MIT
@@ -31,9 +31,12 @@ Provides-Extra: keyring
31
31
  Requires-Dist: keyring>=24.0; extra == "keyring"
32
32
  Provides-Extra: tui
33
33
  Requires-Dist: textual>=0.40.0; extra == "tui"
34
+ Provides-Extra: pdf
35
+ Requires-Dist: pdfplumber>=0.10.0; extra == "pdf"
34
36
  Provides-Extra: all
35
37
  Requires-Dist: keyring>=24.0; extra == "all"
36
38
  Requires-Dist: textual>=0.40.0; extra == "all"
39
+ Requires-Dist: pdfplumber>=0.10.0; extra == "all"
37
40
  Provides-Extra: dev
38
41
  Requires-Dist: build>=1.2.0; extra == "dev"
39
42
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -10,6 +10,7 @@ pyreadline3>=3.4
10
10
  [all]
11
11
  keyring>=24.0
12
12
  textual>=0.40.0
13
+ pdfplumber>=0.10.0
13
14
 
14
15
  [dev]
15
16
  build>=1.2.0
@@ -20,5 +21,8 @@ twine>=5.0.0
20
21
  [keyring]
21
22
  keyring>=24.0
22
23
 
24
+ [pdf]
25
+ pdfplumber>=0.10.0
26
+
23
27
  [tui]
24
28
  textual>=0.40.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "msapling-cli"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "MSapling CLI - Multi-chat AI development environment in your terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -35,7 +35,8 @@ dependencies = [
35
35
  [project.optional-dependencies]
36
36
  keyring = ["keyring>=24.0"]
37
37
  tui = ["textual>=0.40.0"]
38
- all = ["keyring>=24.0", "textual>=0.40.0"]
38
+ pdf = ["pdfplumber>=0.10.0"]
39
+ all = ["keyring>=24.0", "textual>=0.40.0", "pdfplumber>=0.10.0"]
39
40
  dev = ["build>=1.2.0", "pytest>=8.0", "pytest-asyncio>=0.23", "twine>=5.0.0"]
40
41
 
41
42
  [project.scripts]
@@ -151,7 +151,7 @@ def test_safe_path_blocks_prefixed_sibling(tmp_path):
151
151
 
152
152
 
153
153
  def test_tools_schema_complete():
154
- assert len(AGENT_TOOLS_SCHEMA) == 7
154
+ assert len(AGENT_TOOLS_SCHEMA) == 14
155
155
  names = {tool["name"] for tool in AGENT_TOOLS_SCHEMA}
156
156
  assert "read_file" in names
157
157
  assert "write_file" in names
@@ -159,6 +159,13 @@ def test_tools_schema_complete():
159
159
  assert "run_command" in names
160
160
  assert "glob_files" in names
161
161
  assert "grep_search" in names
162
+ assert "cost_check" in names
163
+ assert "model_switch" in names
164
+ assert "search_replace_all" in names
165
+ assert "todo_list" in names
166
+ assert "ask_user" in names
167
+ assert "list_directory" in names
168
+ assert "web_fetch" in names
162
169
 
163
170
 
164
171
  def test_max_steps_bounded():
@@ -117,3 +117,43 @@ async def test_projects_overview_falls_back_to_legacy_projects():
117
117
  assert overview["projects"][0]["name"] == "Default Project"
118
118
  assert overview["projects"][0]["chat_limit"] == 10
119
119
  assert overview["projects"][1]["chat_limit"] == 6
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_mdrive_write_uses_scoped_path_payload():
124
+ client = MSaplingClient(api_url="http://test:8000", token="test-token")
125
+ response = MagicMock()
126
+ response.status_code = 200
127
+ response.raise_for_status = MagicMock()
128
+ response.json.return_value = {"status": "success"}
129
+
130
+ mock_client = AsyncMock()
131
+ mock_client.post = AsyncMock(return_value=response)
132
+
133
+ with patch.object(client, "_get_client", AsyncMock(return_value=mock_client)):
134
+ result = await client.mdrive_write("Default Project", "src/app.py", "print(1)")
135
+
136
+ assert result == {"status": "success"}
137
+ _, kwargs = mock_client.post.call_args
138
+ assert kwargs["json"]["path"] == "Default Project/src/app.py"
139
+ assert kwargs["json"]["content"] == "print(1)"
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_list_project_chats_uses_trailing_slash():
144
+ client = MSaplingClient(api_url="http://test:8000", token="test-token")
145
+ response = MagicMock()
146
+ response.status_code = 200
147
+ response.raise_for_status = MagicMock()
148
+ response.json.return_value = {"projects": {"Default Project": {"chats": [{"id": "1"}]}}}
149
+
150
+ mock_client = AsyncMock()
151
+ mock_client.get = AsyncMock(return_value=response)
152
+
153
+ with patch.object(client, "_get_client", AsyncMock(return_value=mock_client)):
154
+ chats = await client.list_project_chats("Default Project")
155
+
156
+ assert chats == [{"id": "1"}]
157
+ args, kwargs = mock_client.get.call_args
158
+ assert args[0] == "/api/projects/"
159
+ assert kwargs["params"] == {"limit": 100}
@@ -10,7 +10,7 @@ from msapling_cli.config import Settings, get_settings, save_settings, get_token
10
10
  def test_default_settings():
11
11
  s = Settings()
12
12
  assert s.api_url == "https://api.msapling.com"
13
- assert s.default_model == "google/gemini-flash-1.5"
13
+ assert s.default_model == "google/gemini-2.0-flash-001"
14
14
  assert s.temperature == 0.7
15
15
  assert s.theme == "dark"
16
16
 
@@ -5,6 +5,7 @@ from msapling_cli.shell import (
5
5
  INSTRUCTION_FILES,
6
6
  _load_hooks,
7
7
  _load_project_instructions,
8
+ _parse_menu_number,
8
9
  _safe_path,
9
10
  _save_hooks,
10
11
  )
@@ -89,4 +90,20 @@ def test_instruction_files_list():
89
90
  assert ".msapling.md" in INSTRUCTION_FILES
90
91
  assert "CLAUDE.md" in INSTRUCTION_FILES
91
92
  assert "GEMINI.md" in INSTRUCTION_FILES
92
- assert "AGENTS.md" in INSTRUCTION_FILES
93
+ assert "AGENTS.md" in INSTRUCTION_FILES
94
+
95
+ def test_parse_menu_number_resume_variants():
96
+ assert _parse_menu_number("1", "R", allow_bare_number=True) == 1
97
+ assert _parse_menu_number("r 1", "R", allow_bare_number=True) == 1
98
+ assert _parse_menu_number("R1", "R", allow_bare_number=True) == 1
99
+ assert _parse_menu_number("R<1>", "R", allow_bare_number=True) == 1
100
+ assert _parse_menu_number("R <2>", "R", allow_bare_number=True) == 2
101
+
102
+
103
+ def test_parse_menu_number_delete_and_multi_variants():
104
+ assert _parse_menu_number("D1", "D") == 1
105
+ assert _parse_menu_number("D <3>", "D") == 3
106
+ assert _parse_menu_number("M2", "M") == 2
107
+ assert _parse_menu_number("M <4>", "M") == 4
108
+ assert _parse_menu_number("R", "R", allow_bare_number=True) is None
109
+ assert _parse_menu_number("", "R", allow_bare_number=True) is None
File without changes
File without changes
File without changes