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.
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/PKG-INFO +4 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/__init__.py +1 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/agent.py +318 -14
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/api.py +16 -8
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/completer.py +5 -2
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/config.py +5 -2
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/main.py +1 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/shell.py +122 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/PKG-INFO +4 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/requires.txt +4 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/pyproject.toml +3 -2
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_agent.py +8 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_api.py +40 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_config.py +1 -1
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/LICENSE +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/README.md +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/local.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/mcp/__init__.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/mcp/server.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/memory.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/session.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/storage.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/tier.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/tui.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli/worker_pool.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/SOURCES.txt +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/dependency_links.txt +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/entry_points.txt +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/msapling_cli.egg-info/top_level.txt +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/setup.cfg +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_local.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_mcp.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_memory.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_session.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_shell.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_storage.py +0 -0
- {msapling_cli-0.1.3 → msapling_cli-0.1.4}/tests/test_tier.py +0 -0
- {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
|
+
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.
|
|
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({
|
|
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
|
|
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
|
-
-
|
|
168
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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 (
|
|
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 == "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
459
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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=
|
|
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("
|
|
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
|
+
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"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "msapling-cli"
|
|
7
|
-
version = "0.1.
|
|
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
|
-
|
|
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) ==
|
|
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-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|