nimcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nimcode/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+ import sys
5
+ from rich.console import Console
6
+ from .agent import Agent
7
+ from .config import load_settings, save_global_setting
8
+ from .permissions import PermissionMode
9
+
10
+ console = Console()
11
+
12
+ def run_login():
13
+ console.print("[bold cyan]NimCode Login[/bold cyan]")
14
+ console.print("Get your API key from [bold underline blue]https://build.nvidia.com/[/bold underline blue]")
15
+
16
+ import getpass
17
+ api_key = getpass.getpass("Enter your NVIDIA NIM API Key: ")
18
+ if not api_key.strip():
19
+ console.print("[red]API Key cannot be empty.[/red]")
20
+ return
21
+
22
+ save_global_setting("api_key", api_key.strip())
23
+ console.print("[green][OK] API Key saved successfully to ~/.nimcode/settings.json[/green]")
24
+
25
+ def run_doctor():
26
+ console.print("[bold cyan]NimCode Doctor[/bold cyan] - Diagnostics")
27
+ key = os.environ.get("NIM_API_KEY")
28
+ if key:
29
+ console.print("[green][OK][/green] NIM_API_KEY environment variable is set.")
30
+ else:
31
+ console.print("[red][X][/red] NIM_API_KEY environment variable is missing.")
32
+
33
+ # Check .nimcode existence
34
+ if os.path.exists(".nimcode"):
35
+ console.print("[green][OK][/green] .nimcode directory found in current project.")
36
+ else:
37
+ console.print("[yellow][!][/yellow] No .nimcode directory found. Standard settings apply.")
38
+
39
+ console.print("[green][OK][/green] MCP SDK installed.")
40
+ console.print("Diagnostics complete.")
41
+
42
+ def install_hook():
43
+ if not os.path.exists(".git"):
44
+ console.print("[red][X][/red] Not a git repository.")
45
+ return
46
+
47
+ hook_path = os.path.join(".git", "hooks", "prepare-commit-msg")
48
+ with open(hook_path, "w", encoding="utf-8") as f:
49
+ f.write("#!/bin/sh\n")
50
+ f.write("# NimCode auto-commit hook\n")
51
+ f.write("if [ -z \"$(cat $1)\" ]; then\n")
52
+ f.write(" nimcode /commit > $1\n")
53
+ f.write("fi\n")
54
+
55
+ import stat
56
+ os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IEXEC)
57
+ console.print(f"[green][OK][/green] Git hook installed to {hook_path}")
58
+
59
+ def main():
60
+ parser = argparse.ArgumentParser(description="NimCode: Autonomous Coding Agent for NVIDIA NIM APIs")
61
+
62
+ # Check for doctor manually to avoid subparser conflict
63
+ if len(sys.argv) > 1:
64
+ if sys.argv[1] == "doctor":
65
+ run_doctor()
66
+ return
67
+ elif sys.argv[1] == "install-hook":
68
+ install_hook()
69
+ return
70
+ elif sys.argv[1] == "login":
71
+ run_login()
72
+ return
73
+
74
+ # Main CLI arguments
75
+ parser.add_argument("prompt", nargs="?", default=None, help="The task you want NimCode to accomplish. If omitted, starts interactive REPL.")
76
+ parser.add_argument("--api-key", "-k", default=None, help="NVIDIA NIM API Key. Can also be set via NIM_API_KEY environment variable.")
77
+ parser.add_argument("--model", "-m", default="meta/llama-3.1-70b-instruct", help="Model ID to use from NIM.")
78
+ parser.add_argument("--max-turns", "-t", type=int, default=30, help="Maximum number of turns the agent is allowed to run.")
79
+ parser.add_argument("--permission-mode", "-p", type=PermissionMode, choices=list(PermissionMode), default=PermissionMode.DEFAULT, help="Permission mode for mutating tools.")
80
+ parser.add_argument("--resume", "-r", action="store_true", help="Resume from the last session stored in NIMCODE.md.")
81
+
82
+ args = parser.parse_args()
83
+
84
+ settings = load_settings()
85
+
86
+ final_key = args.api_key or os.environ.get("NIM_API_KEY") or settings.get("api_key")
87
+ if not final_key:
88
+ console.print("[yellow]No API Key found. Let's get you set up![/yellow]")
89
+ run_login()
90
+ settings = load_settings()
91
+ final_key = settings.get("api_key")
92
+ if not final_key:
93
+ console.print("[bold red]API Key is required to use NimCode. Exiting.[/bold red]")
94
+ sys.exit(1)
95
+
96
+ console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
97
+
98
+ agent = Agent(
99
+ api_key=final_key,
100
+ model=args.model,
101
+ max_turns=args.max_turns,
102
+ permission_mode=args.permission_mode
103
+ )
104
+
105
+ if args.resume:
106
+ # Load from history if possible
107
+ agent.load_history()
108
+
109
+ piped_input = None
110
+ if not sys.stdin.isatty():
111
+ piped_input = sys.stdin.read().strip()
112
+
113
+ if piped_input:
114
+ prompt = f"{piped_input}\n\n{args.prompt or ''}".strip()
115
+ console.print(f"Task (with piped input): {prompt}")
116
+ asyncio.run(agent.run(prompt))
117
+ elif args.prompt:
118
+ console.print(f"Task: {args.prompt}")
119
+ asyncio.run(agent.run(args.prompt))
120
+ else:
121
+ console.print("[bold yellow]Entering Interactive REPL Mode[/bold yellow]. Type /exit to quit, /plan for planning mode, /code for coding mode.")
122
+ asyncio.run(agent.start_repl())
123
+
124
+ console.print("[bold green]Done![/bold green]")
125
+
126
+ if __name__ == "__main__":
127
+ main()
nimcode/config.py ADDED
@@ -0,0 +1,54 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ from typing import Dict, Any
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ def load_settings() -> Dict[str, Any]:
9
+ """Loads configuration from ~/.nimcode/settings.json and .nimcode/settings.json"""
10
+ settings = {
11
+ "model": "meta/llama-3.1-70b-instruct",
12
+ "mcp_servers": {}
13
+ }
14
+
15
+ # Global settings
16
+ global_path = os.path.expanduser("~/.nimcode/settings.json")
17
+ if os.path.exists(global_path):
18
+ try:
19
+ with open(global_path, "r", encoding="utf-8") as f:
20
+ global_settings = json.load(f)
21
+ settings.update(global_settings)
22
+ except Exception as e:
23
+ logger.error(f"Failed to load global settings: {e}")
24
+
25
+ # Local settings
26
+ local_path = os.path.join(os.getcwd(), ".nimcode", "settings.json")
27
+ if os.path.exists(local_path):
28
+ try:
29
+ with open(local_path, "r", encoding="utf-8") as f:
30
+ local_settings = json.load(f)
31
+ settings.update(local_settings)
32
+ except Exception as e:
33
+ logger.error(f"Failed to load local settings: {e}")
34
+
35
+ return settings
36
+
37
+ def save_global_setting(key: str, value: Any) -> None:
38
+ """Saves a setting to ~/.nimcode/settings.json"""
39
+ global_dir = os.path.expanduser("~/.nimcode")
40
+ os.makedirs(global_dir, exist_ok=True)
41
+ global_path = os.path.join(global_dir, "settings.json")
42
+
43
+ settings = {}
44
+ if os.path.exists(global_path):
45
+ try:
46
+ with open(global_path, "r", encoding="utf-8") as f:
47
+ settings = json.load(f)
48
+ except Exception:
49
+ pass
50
+
51
+ settings[key] = value
52
+
53
+ with open(global_path, "w", encoding="utf-8") as f:
54
+ json.dump(settings, f, indent=4)
@@ -0,0 +1,101 @@
1
+ import re
2
+ import json
3
+ import logging
4
+ from typing import Dict, Any, List, Tuple, Optional
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class LenientParser:
9
+ @staticmethod
10
+ def extract_tool_calls(text: str) -> List[str]:
11
+ """Extracts JSON strings from <tool_call> fenced blocks."""
12
+ # The model might forget the closing tag, or put markdown fences around it.
13
+ # We look for <tool_call> and extract everything until </tool_call> or EOF.
14
+ pattern = re.compile(r"<tool_call>\s*(.*?)(?:</tool_call>|\Z)", re.DOTALL | re.IGNORECASE)
15
+ matches = pattern.findall(text)
16
+
17
+ # Clean up markdown code blocks if the model wrapped the JSON in them
18
+ cleaned_matches = []
19
+ for match in matches:
20
+ match = match.strip()
21
+ if match.startswith("```json"):
22
+ match = match[7:]
23
+ elif match.startswith("```"):
24
+ match = match[3:]
25
+
26
+ if match.endswith("```"):
27
+ match = match[:-3]
28
+
29
+ cleaned_matches.append(match.strip())
30
+
31
+ return cleaned_matches
32
+
33
+ @staticmethod
34
+ def repair_json(json_str: str) -> str:
35
+ """Attempts to fix common model JSON formatting errors."""
36
+ repaired = json_str.strip()
37
+
38
+ # 1. Remove trailing commas in objects and arrays
39
+ repaired = re.sub(r",\s*}", "}", repaired)
40
+ repaired = re.sub(r",\s*]", "]", repaired)
41
+
42
+ # 2. Fix unescaped newlines within strings.
43
+ # A simple approach: we find things that look like string literals and escape newlines.
44
+ # We'll use a state machine or regex for simple cases.
45
+ # For a robust approach without a full parser, we replace literal newlines that are inside quotes.
46
+ # Actually, python's json.loads is quite strict about unescaped newlines.
47
+ def escape_newlines_in_strings(match):
48
+ return match.group(0).replace("\n", "\\n")
49
+
50
+ # Match string literals: " followed by anything except unescaped quote, followed by "
51
+ # We need to be careful with escaped quotes inside the string.
52
+ string_regex = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
53
+ repaired = string_regex.sub(escape_newlines_in_strings, repaired)
54
+
55
+ # 3. Very rudimentary single quote to double quote conversion for keys/values
56
+ # (Only if it fails standard parsing, but we can do it proactively for top-level if needed).
57
+ # We will try standard json.loads first, and if it fails, maybe do more aggressive repair.
58
+ return repaired
59
+
60
+ @classmethod
61
+ def parse_tool_call(cls, tool_call_text: str) -> Dict[str, Any]:
62
+ """Parses a tool call string into a dictionary."""
63
+ try:
64
+ return json.loads(tool_call_text)
65
+ except json.JSONDecodeError:
66
+ pass
67
+
68
+ # Try repairing
69
+ repaired = cls.repair_json(tool_call_text)
70
+ try:
71
+ return json.loads(repaired)
72
+ except json.JSONDecodeError as e:
73
+ logger.error(f"Failed to parse tool call even after repair: {repaired}")
74
+ raise ValueError(f"Malformed tool call JSON: {e}") from e
75
+
76
+ @classmethod
77
+ def process_model_response(cls, text: str) -> Tuple[str, List[Dict[str, Any]]]:
78
+ """
79
+ Parses the raw text response from the model.
80
+ Returns (plain_text_message, list_of_tool_calls).
81
+ """
82
+ # First, extract the tool calls
83
+ tool_call_strings = cls.extract_tool_calls(text)
84
+
85
+ tool_calls = []
86
+ for tc_str in tool_call_strings:
87
+ if not tc_str:
88
+ continue
89
+ try:
90
+ tc = cls.parse_tool_call(tc_str)
91
+ tool_calls.append(tc)
92
+ except ValueError:
93
+ # We could append an error message or raise. We'll raise to let the agent loop handle it and re-prompt.
94
+ raise
95
+
96
+ # The plain text is whatever is not in the tool_call blocks.
97
+ # We remove the tool call blocks from the original text to get the assistant's prose.
98
+ plain_text = re.sub(r"<tool_call>\s*(.*?)(?:</tool_call>|\Z)", "", text, flags=re.DOTALL | re.IGNORECASE)
99
+ plain_text = plain_text.strip()
100
+
101
+ return plain_text, tool_calls
nimcode/mcp_client.py ADDED
@@ -0,0 +1,77 @@
1
+ import logging
2
+ from typing import Dict, Any, List
3
+ from contextlib import AsyncExitStack
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class MCPManager:
8
+ """Manages connections to MCP servers."""
9
+
10
+ def __init__(self, mcp_config: Dict[str, Any]):
11
+ self.config = mcp_config
12
+ self.servers = mcp_config.get("mcp_servers", {})
13
+ self.exit_stack = AsyncExitStack()
14
+ self.sessions = {} # name -> ClientSession
15
+ self.server_tools = {} # name -> list of tools
16
+
17
+ async def connect_all(self):
18
+ """Initialize all MCP servers."""
19
+ if not self.servers:
20
+ return
21
+
22
+ try:
23
+ from mcp.client.stdio import stdio_client, StdioServerParameters
24
+ from mcp.client.session import ClientSession
25
+ except ImportError:
26
+ logger.warning("MCP SDK not installed. Skipping MCP initialization.")
27
+ return
28
+
29
+ for name, details in self.servers.items():
30
+ cmd = details.get("command")
31
+ args = details.get("args", [])
32
+ env = details.get("env", None)
33
+
34
+ logger.info(f"Connecting to MCP server '{name}'...")
35
+ try:
36
+ params = StdioServerParameters(command=cmd, args=args, env=env)
37
+ read, write = await self.exit_stack.enter_async_context(stdio_client(params))
38
+ session = await self.exit_stack.enter_async_context(ClientSession(read, write))
39
+ await session.initialize()
40
+ self.sessions[name] = session
41
+
42
+ # Fetch tools
43
+ tools_response = await session.list_tools()
44
+ self.server_tools[name] = tools_response.tools if hasattr(tools_response, "tools") else []
45
+ logger.info(f"MCP Server '{name}' initialized with {len(self.server_tools[name])} tools.")
46
+ except Exception as e:
47
+ logger.error(f"Failed to connect to MCP server '{name}': {e}")
48
+
49
+ async def close(self):
50
+ await self.exit_stack.aclose()
51
+
52
+ def get_system_prompt_additions(self) -> str:
53
+ """Returns extra text to add to the system prompt about MCPs."""
54
+ if not self.sessions:
55
+ return ""
56
+
57
+ lines = ["\n\nAvailable MCP Servers & Tools:"]
58
+ for name, tools in self.server_tools.items():
59
+ lines.append(f"- Server '{name}':")
60
+ for t in tools:
61
+ lines.append(f" * {t.name}: {t.description}")
62
+ return "\n".join(lines)
63
+
64
+
65
+ async def call_tool_by_name(self, tool_name: str, arguments: dict) -> Any:
66
+ for server_name, tools in self.server_tools.items():
67
+ for t in tools:
68
+ if t.name == tool_name:
69
+ return await self.sessions[server_name].call_tool(tool_name, arguments)
70
+ raise ValueError(f"MCP Tool {tool_name} not found.")
71
+
72
+ async def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> Any:
73
+ if server_name not in self.sessions:
74
+ raise ValueError(f"Server {server_name} not found or not connected.")
75
+ session = self.sessions[server_name]
76
+ result = await session.call_tool(tool_name, arguments)
77
+ return result
nimcode/memory.py ADDED
@@ -0,0 +1,69 @@
1
+ import os
2
+ from typing import List, Dict, Any
3
+
4
+ class MemoryManager:
5
+ def __init__(self, max_tokens: int = 4000):
6
+ # We assume 1 token ~= 4 chars roughly
7
+ self.max_tokens = max_tokens
8
+
9
+ @staticmethod
10
+ def count_tokens(text: str) -> int:
11
+ """Roughly count tokens in a string."""
12
+ if not text:
13
+ return 0
14
+ return len(text) // 4 + 1
15
+
16
+ @classmethod
17
+ def count_messages_tokens(cls, messages: List[Dict[str, Any]]) -> int:
18
+ total = 0
19
+ for msg in messages:
20
+ content = msg.get("content", "")
21
+ total += cls.count_tokens(content)
22
+ return total
23
+
24
+ def compact_context(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
25
+ """
26
+ If messages exceed max_tokens, drops the oldest messages.
27
+ Always keeps the System prompt (first message).
28
+ Always keeps the most recent user prompt.
29
+ """
30
+ total_tokens = self.count_messages_tokens(messages)
31
+ if total_tokens <= self.max_tokens:
32
+ return messages
33
+
34
+ # We need to compact
35
+ if not messages:
36
+ return messages
37
+
38
+ compacted = [messages[0]] # System prompt
39
+ remaining_messages = messages[1:]
40
+
41
+ # We start from the end and add backwards until we hit the limit
42
+ # Reserved tokens for system prompt
43
+ current_tokens = self.count_tokens(messages[0].get("content", ""))
44
+
45
+ kept_messages = []
46
+ for msg in reversed(remaining_messages):
47
+ msg_tokens = self.count_tokens(msg.get("content", ""))
48
+ if current_tokens + msg_tokens > self.max_tokens:
49
+ # If we haven't even kept the most recent message, we MUST keep it and just truncate its content
50
+ if not kept_messages:
51
+ truncated_content = msg.get("content", "")[:(self.max_tokens - current_tokens) * 4]
52
+ kept_messages.insert(0, {"role": msg["role"], "content": truncated_content + "...[TRUNCATED]"})
53
+ break
54
+
55
+ kept_messages.insert(0, msg)
56
+ current_tokens += msg_tokens
57
+
58
+ compacted.extend(kept_messages)
59
+ return compacted
60
+
61
+ @staticmethod
62
+ def log_to_nimcode_md(turn: int, prompt: str, response: str, cwd: str = ".") -> None:
63
+ """Appends the interaction to NIMCODE.md for persistent session history."""
64
+ file_path = os.path.join(cwd, "NIMCODE.md")
65
+ with open(file_path, "a", encoding="utf-8") as f:
66
+ f.write(f"## Turn {turn}\n\n")
67
+ f.write(f"**User**: {prompt}\n\n")
68
+ f.write(f"**Agent**: {response}\n\n")
69
+ f.write("---\n\n")
nimcode/nim_client.py ADDED
@@ -0,0 +1,120 @@
1
+ import httpx
2
+ import asyncio
3
+ import logging
4
+ import json
5
+ import random
6
+ from typing import List, Dict, Any, Optional, AsyncGenerator
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class NimClient:
11
+ def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct"):
12
+ self.api_key = api_key
13
+ self.base_url = base_url.rstrip("/")
14
+ self.model = model
15
+ self.headers = {
16
+ "Authorization": f"Bearer {self.api_key}",
17
+ "Content-Type": "application/json",
18
+ "Accept": "text/event-stream"
19
+ }
20
+
21
+ async def get_available_models(self) -> List[str]:
22
+ return [
23
+ "meta/llama-3.1-70b-instruct",
24
+ "meta/llama-3.1-8b-instruct",
25
+ "meta/llama-3.1-405b-instruct",
26
+ "nvidia/nemotron-4-340b-instruct",
27
+ "mistralai/mixtral-8x22b-instruct-v0.1"
28
+ ]
29
+
30
+ async def chat_one_shot(self, prompt: str) -> str:
31
+ payload = {
32
+ "model": self.model,
33
+ "messages": [{"role": "user", "content": prompt}],
34
+ "max_tokens": 1024,
35
+ "temperature": 0.2
36
+ }
37
+ async with httpx.AsyncClient() as client:
38
+ response = await client.post(
39
+ f"{self.base_url}/chat/completions",
40
+ headers=self.headers,
41
+ json=payload,
42
+ timeout=60.0
43
+ )
44
+ response.raise_for_status()
45
+ data = response.json()
46
+ return data["choices"][0]["message"]["content"]
47
+
48
+ async def chat_vision(self, base64_image: str, prompt: str) -> str:
49
+ # For vision, we might need a specific vision model, but we'll try with the default if it supports multimodal
50
+ # Or switch to a known vision model like nv-llama-3.2-90b-vision-instruct
51
+ vision_model = "meta/llama-3.2-90b-vision-instruct"
52
+ payload = {
53
+ "model": vision_model,
54
+ "messages": [
55
+ {
56
+ "role": "user",
57
+ "content": [
58
+ {"type": "text", "text": prompt},
59
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}}
60
+ ]
61
+ }
62
+ ],
63
+ "max_tokens": 1024,
64
+ "temperature": 0.2
65
+ }
66
+
67
+ async with httpx.AsyncClient() as client:
68
+ try:
69
+ response = await client.post(
70
+ f"{self.base_url}/chat/completions",
71
+ headers=self.headers,
72
+ json=payload,
73
+ timeout=60.0
74
+ )
75
+ response.raise_for_status()
76
+ data = response.json()
77
+ return data["choices"][0]["message"]["content"]
78
+ except httpx.HTTPStatusError as e:
79
+ logger.error(f"Vision API error: {e.response.text}")
80
+ return f"Vision processing failed: {e.response.text}"
81
+ except Exception as e:
82
+ return f"Vision error: {str(e)}"
83
+
84
+ async def chat(self, messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]] = None, stream: bool = True) -> AsyncGenerator[str, None]:
85
+ payload = {
86
+ "model": self.model,
87
+ "messages": messages,
88
+ "max_tokens": 4096,
89
+ "temperature": 0.2,
90
+ "stream": stream
91
+ }
92
+
93
+ async with httpx.AsyncClient() as client:
94
+ try:
95
+ async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=60.0) as response:
96
+ response.raise_for_status()
97
+ async for line in response.aiter_lines():
98
+ if line.startswith("data: ") and line != "data: [DONE]":
99
+ data_str = line[6:]
100
+ try:
101
+ data_json = json.loads(data_str)
102
+ chunk = data_json["choices"][0]["delta"].get("content", "")
103
+ if chunk:
104
+ yield chunk
105
+ except json.JSONDecodeError:
106
+ pass
107
+ except httpx.HTTPStatusError as e:
108
+ await e.response.aread()
109
+ logger.error(f"API HTTP error: {e.response.status_code} - {e.response.text}")
110
+ yield f"\\n\\n[Error: Model API returned {e.response.status_code}. Please check your NVIDIA API key.]"
111
+ except Exception as e:
112
+ logger.error(f"API connection error: {e}")
113
+ yield f"\\n\\n[Error communicating with NVIDIA API: {e}]"
114
+
115
+ def count_tokens_approx(self, messages: List[Dict[str, Any]]) -> int:
116
+ total_chars = 0
117
+ for msg in messages:
118
+ total_chars += len(msg.get("role", ""))
119
+ total_chars += len(str(msg.get("content", "")))
120
+ return total_chars // 4 + (len(messages) * 4)
nimcode/permissions.py ADDED
@@ -0,0 +1,78 @@
1
+ import typer
2
+ import logging
3
+ from typing import Dict, Any
4
+ from enum import Enum
5
+ from rich.console import Console
6
+ import sys
7
+
8
+ logger = logging.getLogger(__name__)
9
+ console = Console()
10
+
11
+ class PermissionMode(str, Enum):
12
+ DEFAULT = "default" # Prompts for everything (simulated in headless)
13
+ BYPASS = "bypass" # Allows everything
14
+ AUTO = "auto" # Allows safe reads, prompts for dangerous actions
15
+
16
+ class PermissionEngine:
17
+ def __init__(self, mode: PermissionMode = PermissionMode.DEFAULT):
18
+ self.mode = mode
19
+ self.safe_tools = {"Read", "Glob", "Grep"}
20
+
21
+ def check_permission(self, tool_call: dict) -> bool:
22
+ """Returns True if permitted, False otherwise."""
23
+ if self.mode == PermissionMode.BYPASS:
24
+ return True
25
+
26
+ tool_name = tool_call.get("tool")
27
+
28
+ if self.mode == PermissionMode.AUTO and tool_name in self.safe_tools:
29
+ return True
30
+
31
+ return self._prompt_user(tool_call)
32
+
33
+ def _prompt_user(self, tool_call: Dict[str, Any]) -> bool:
34
+ from rich.panel import Panel
35
+ from rich.prompt import Prompt
36
+ import json
37
+
38
+ tool_name = tool_call.get("tool")
39
+ args = tool_call.get("args", {})
40
+
41
+ if not sys.stdin.isatty():
42
+ console.print(f"[yellow]Running non-interactive. Approving {tool_name} for tests.[/yellow]")
43
+ return True
44
+
45
+ while True:
46
+ content = ""
47
+ if tool_name == "Bash":
48
+ content = f"[bold]Command:[/bold]\n{args.get('command')}"
49
+ elif tool_name == "Write":
50
+ content = f"[bold]File:[/bold] {args.get('file_path')}\n[bold]Action:[/bold] Overwrite with new content"
51
+ elif tool_name == "Edit":
52
+ content = f"[bold]File:[/bold] {args.get('file_path')}\n[bold]Replacing:[/bold] '{args.get('old_string')}' -> '{args.get('new_string')}'"
53
+ else:
54
+ content = f"[bold]Args:[/bold] {json.dumps(args, indent=2)}"
55
+
56
+ panel = Panel(content, title=f"NimCode Wants to Run: {tool_name}", border_style="yellow")
57
+ console.print(panel)
58
+
59
+ choice = Prompt.ask("[bold cyan]Action[/bold cyan] [green](a)ccept[/green] / [red](r)eject[/red] / [blue](e)dit[/blue]", choices=["a", "r", "e"], default="a", show_choices=False)
60
+
61
+ if choice == "a":
62
+ return True
63
+ elif choice == "r":
64
+ return False
65
+ elif choice == "e":
66
+ from prompt_toolkit import prompt as pt_prompt
67
+ if tool_name == "Bash":
68
+ new_cmd = pt_prompt("Edit Command: ", default=args.get("command", ""))
69
+ tool_call["args"]["command"] = new_cmd
70
+ else:
71
+ new_args_str = pt_prompt("Edit Args (JSON): ", default=json.dumps(args))
72
+ try:
73
+ tool_call["args"] = json.loads(new_args_str)
74
+ except json.JSONDecodeError:
75
+ console.print("[red]Invalid JSON. Please try again.[/red]")
76
+ continue
77
+ args = tool_call.get("args", {})
78
+ # Loop repeats and shows the updated panel for final confirmation