msapling-cli 0.1.3__tar.gz → 0.1.4__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.4}/PKG-INFO +4 -1
  2. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/__init__.py +1 -1
  3. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/agent.py +318 -14
  4. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/api.py +16 -8
  5. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/completer.py +5 -2
  6. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/config.py +5 -2
  7. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/main.py +1 -1
  8. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/shell.py +122 -1
  9. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/PKG-INFO +4 -1
  10. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/requires.txt +4 -0
  11. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/pyproject.toml +3 -2
  12. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_agent.py +8 -1
  13. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_api.py +40 -0
  14. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_config.py +1 -1
  15. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/LICENSE +0 -0
  16. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/README.md +0 -0
  17. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/local.py +0 -0
  18. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/mcp/__init__.py +0 -0
  19. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/mcp/server.py +0 -0
  20. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/memory.py +0 -0
  21. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/session.py +0 -0
  22. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/storage.py +0 -0
  23. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/tier.py +0 -0
  24. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/tui.py +0 -0
  25. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/worker_pool.py +0 -0
  26. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/SOURCES.txt +0 -0
  27. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/dependency_links.txt +0 -0
  28. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/entry_points.txt +0 -0
  29. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/top_level.txt +0 -0
  30. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/setup.cfg +0 -0
  31. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_local.py +0 -0
  32. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_mcp.py +0 -0
  33. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_memory.py +0 -0
  34. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_session.py +0 -0
  35. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_shell.py +0 -0
  36. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_storage.py +0 -0
  37. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_tier.py +0 -0
  38. {msapling_cli-0.1.3 → msapling_cli-0.1.4}/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.4
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.4"
@@ -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
 
@@ -271,6 +294,7 @@ class InteractiveShell:
271
294
  instructions = _load_project_instructions(self.project_root)
272
295
  if instructions:
273
296
  self.messages.append({"role": "system", "content": instructions})
297
+ _run_hooks("on_instructions_loaded", self.hooks, self.project_root)
274
298
 
275
299
  memories = get_memories(self.project_root)
276
300
  if memories:
@@ -303,6 +327,8 @@ class InteractiveShell:
303
327
  console.print(f"[dim][hook] {out}[/dim]")
304
328
 
305
329
  await self._main_loop()
330
+ for out in _run_hooks("on_session_end", self.hooks, self.project_root):
331
+ console.print(f"[dim][hook] {out}[/dim]")
306
332
  self._save()
307
333
  unregister_session(os.getpid())
308
334
  console.print("[ms.dim]Session saved. Resume with: msapling shell --resume[/ms.dim]")
@@ -482,6 +508,7 @@ class InteractiveShell:
482
508
  await self._setup_workers(count)
483
509
  if self.workers:
484
510
  self.chat_id = self.workers[0]["chat_id"]
511
+ self.model = self.workers[0]["model"]
485
512
  return True
486
513
  continue
487
514
 
@@ -624,6 +651,7 @@ class InteractiveShell:
624
651
  return True
625
652
  if self._total_session_cost() >= self.cost_ceiling:
626
653
  console.print(f"[red]Cost ceiling reached (${self.cost_ceiling:.4f}). Use /budget to adjust.[/red]")
654
+ _run_hooks("on_budget_exceeded", self.hooks, self.project_root)
627
655
  return False
628
656
  return True
629
657
 
@@ -677,11 +705,13 @@ class InteractiveShell:
677
705
  on_text=lambda text: None,
678
706
  on_usage=_on_usage,
679
707
  project_name=self.project_name or "",
708
+ chat_id=self.chat_id or "",
680
709
  )
681
710
  if response:
682
711
  render_assistant_response(response)
683
712
  append_conversation(self.project_root, self.session_id, "assistant", response)
684
713
  render_cost_footer(self.tokens_total, self.cost_total)
714
+ _run_hooks("on_after_message", self.hooks, self.project_root)
685
715
 
686
716
  # Check if agent switched models mid-task
687
717
  new_model = get_and_clear_switched_model()
@@ -708,6 +738,8 @@ class InteractiveShell:
708
738
 
709
739
  try:
710
740
  parts = []
741
+ thinking_parts = []
742
+ in_thinking = False
711
743
  with Live(console=console, refresh_per_second=8) as live:
712
744
  async for chunk in self.client.stream_chat(
713
745
  chat_id=self.chat_id or self.session_id,
@@ -717,14 +749,38 @@ class InteractiveShell:
717
749
  history=history_to_send,
718
750
  ):
719
751
  content = chunk.get("content", "")
752
+ # Detect thinking/reasoning blocks (Claude/GPT extended thinking)
753
+ chunk_type = chunk.get("type", "")
754
+ if chunk_type == "thinking" or chunk.get("thinking"):
755
+ thinking_text = chunk.get("thinking", content)
756
+ if thinking_text:
757
+ thinking_parts.append(thinking_text)
758
+ # Show thinking in dim text
759
+ from rich.text import Text
760
+ thinking_display = Text("".join(thinking_parts), style="dim italic")
761
+ live.update(thinking_display)
762
+ continue
720
763
  if content:
764
+ # If we were showing thinking, clear and switch to main response
765
+ if thinking_parts and not parts:
766
+ console.print() # Newline after thinking
721
767
  parts.append(content)
722
768
  live.update(Markdown("".join(parts)))
723
- if chunk.get("type") == "usage":
769
+ if chunk_type == "usage" or chunk.get("usage"):
724
770
  usage = chunk.get("usage", {})
725
771
  self.tokens_total += usage.get("total_tokens", 0)
726
772
  self.cost_total += float(chunk.get("cost", 0))
727
773
 
774
+ # Show thinking summary if any
775
+ if thinking_parts:
776
+ thinking_text = "".join(thinking_parts)
777
+ if len(thinking_text) > 200:
778
+ console.print(Panel(
779
+ f"[dim italic]{thinking_text[:500]}{'...' if len(thinking_text) > 500 else ''}[/dim italic]",
780
+ title="[dim]Thinking[/dim]",
781
+ border_style="dim",
782
+ ))
783
+
728
784
  response = "".join(parts)
729
785
  if response:
730
786
  self.messages.append({"role": "assistant", "content": response})
@@ -755,6 +811,7 @@ class InteractiveShell:
755
811
  console.print(f"[ms.accent]Model: {old} -> {self.model}[/ms.accent] [ms.dim](matched from '{args.strip()}')[/ms.dim]")
756
812
  else:
757
813
  console.print(f"[ms.accent]Model: {old} -> {self.model}[/ms.accent]")
814
+ _run_hooks("on_model_change", self.hooks, self.project_root)
758
815
  else:
759
816
  console.print(f"Current model: [ms.accent]{self.model}[/ms.accent]")
760
817
  elif command == "/models":
@@ -980,6 +1037,70 @@ class InteractiveShell:
980
1037
  else:
981
1038
  new_mode = set_permission_mode(args.strip().lower())
982
1039
  console.print(f"[green]Permission mode: {new_mode}[/green]")
1040
+ elif command == "/worktree":
1041
+ sub = args.split()[0].lower() if args.strip() else "list"
1042
+ wt_arg = args.split()[1] if len(args.split()) > 1 else ""
1043
+ if sub == "list":
1044
+ try:
1045
+ r = subprocess.run(["git", "worktree", "list"], cwd=self.project_root,
1046
+ capture_output=True, text=True, timeout=10)
1047
+ console.print(r.stdout or "[yellow]No worktrees[/yellow]")
1048
+ except Exception as e:
1049
+ console.print(f"[red]{e}[/red]")
1050
+ elif sub == "create" and wt_arg:
1051
+ branch = wt_arg
1052
+ wt_path = os.path.join(self.project_root, f"../{Path(self.project_root).name}-{branch}")
1053
+ try:
1054
+ r = subprocess.run(
1055
+ ["git", "worktree", "add", wt_path, "-b", branch],
1056
+ cwd=self.project_root, capture_output=True, text=True, timeout=30,
1057
+ )
1058
+ if r.returncode == 0:
1059
+ console.print(f"[green]Worktree created: {wt_path}[/green]")
1060
+ console.print(f"[dim]Switch with: /worktree switch {branch}[/dim]")
1061
+ else:
1062
+ console.print(f"[red]{r.stderr}[/red]")
1063
+ except Exception as e:
1064
+ console.print(f"[red]{e}[/red]")
1065
+ elif sub == "switch" and wt_arg:
1066
+ try:
1067
+ r = subprocess.run(["git", "worktree", "list", "--porcelain"],
1068
+ cwd=self.project_root, capture_output=True, text=True, timeout=10)
1069
+ for line in r.stdout.split("\n"):
1070
+ if line.startswith("worktree ") and wt_arg in line:
1071
+ new_root = line.replace("worktree ", "").strip()
1072
+ self.project_root = new_root
1073
+ console.print(f"[green]Switched to worktree: {new_root}[/green]")
1074
+ break
1075
+ else:
1076
+ console.print(f"[yellow]Worktree not found: {wt_arg}[/yellow]")
1077
+ except Exception as e:
1078
+ console.print(f"[red]{e}[/red]")
1079
+ elif sub == "delete" and wt_arg:
1080
+ try:
1081
+ r = subprocess.run(["git", "worktree", "remove", wt_arg],
1082
+ cwd=self.project_root, capture_output=True, text=True, timeout=10)
1083
+ if r.returncode == 0:
1084
+ console.print(f"[green]Worktree removed: {wt_arg}[/green]")
1085
+ else:
1086
+ console.print(f"[red]{r.stderr}[/red]")
1087
+ except Exception as e:
1088
+ console.print(f"[red]{e}[/red]")
1089
+ else:
1090
+ console.print("[yellow]Usage: /worktree <create|list|switch|delete> [branch][/yellow]")
1091
+ elif command == "/sandbox":
1092
+ from .agent import set_sandbox, _sandbox_enabled
1093
+ if not args.strip():
1094
+ status = "[green]ON[/green]" if _sandbox_enabled else "[red]OFF[/red]"
1095
+ console.print(f"[cyan]Command sandbox:[/cyan] {status}")
1096
+ console.print("[dim] Blocks dangerous commands (rm -rf /, sudo, curl|bash, etc.)")
1097
+ console.print(" /sandbox off — disable | /sandbox on — enable[/dim]")
1098
+ elif args.strip().lower() in ("on", "true", "1"):
1099
+ set_sandbox(True)
1100
+ console.print("[green]Sandbox: ON[/green]")
1101
+ elif args.strip().lower() in ("off", "false", "0"):
1102
+ set_sandbox(False)
1103
+ console.print("[yellow]Sandbox: OFF — dangerous commands allowed[/yellow]")
983
1104
  elif command == "/simple":
984
1105
  self.agent_mode = False
985
1106
  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.4
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.4"
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
 
File without changes
File without changes
File without changes