npcsh 1.0.16__py3-none-any.whl → 1.0.18__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.
npcsh/mcp_npcsh.py DELETED
@@ -1,822 +0,0 @@
1
- #!/usr/bin/env python
2
- # npcsh_mcp.py
3
-
4
- import os
5
- import sys
6
- import atexit
7
- import subprocess
8
- import shlex
9
- import re
10
- from datetime import datetime
11
- import argparse
12
- import importlib.metadata
13
- import textwrap
14
- from typing import Optional, List, Dict, Any, Tuple, Union, Generator, Callable
15
- try:
16
-
17
- from inspect import isgenerator
18
-
19
- except:
20
- pass
21
- import shutil
22
- import asyncio
23
- import json
24
- from contextlib import AsyncExitStack
25
-
26
- from termcolor import colored, cprint
27
- import chromadb
28
-
29
- from mcp import ClientSession, StdioServerParameters
30
- from mcp.client.stdio import stdio_client
31
-
32
- from npcsh._state import (
33
- ShellState,
34
- initial_state,
35
- setup_npcsh_config,
36
- is_npcsh_initialized,
37
- initialize_base_npcs_if_needed,
38
- orange,
39
- interactive_commands,
40
- BASH_COMMANDS,
41
- log_action,
42
- start_interactive_session,
43
- )
44
- from npcpy.npc_sysenv import (
45
- print_and_process_stream_with_markdown,
46
- render_markdown,
47
- get_locally_available_models,
48
- get_model_and_provider,
49
- )
50
- from npcpy.routes import router
51
- from npcpy.data.image import capture_screenshot
52
- from npcpy.memory.command_history import (
53
- CommandHistory,
54
- save_conversation_message,
55
- )
56
- from npcpy.npc_compiler import NPC, Team
57
- from npcpy.gen.embeddings import get_embeddings
58
- from npcpy.gen.response import get_litellm_response, get_ollama_response
59
- from npcpy.llm_funcs import check_llm_command
60
-
61
- import readline
62
-
63
- VERSION = importlib.metadata.version("npcpy")
64
-
65
- TERMINAL_EDITORS = ["vim", "emacs", "nano"]
66
- EMBEDDINGS_DB_PATH = os.path.expanduser("~/npcsh_chroma.db")
67
- HISTORY_DB_DEFAULT_PATH = os.path.expanduser("~/npcsh_history.db")
68
- READLINE_HISTORY_FILE = os.path.expanduser("~/.npcsh_readline_history")
69
- DEFAULT_NPC_TEAM_PATH = os.path.expanduser("~/.npcsh/npc_team/")
70
- PROJECT_NPC_TEAM_PATH = "./npc_team/"
71
-
72
- chroma_client = chromadb.PersistentClient(path=EMBEDDINGS_DB_PATH)
73
-
74
- class CommandNotFoundError(Exception):
75
- pass
76
-
77
- class MCPClientNPC:
78
- def __init__(self, debug: bool = True):
79
- self.debug = debug
80
- self.session: Optional[ClientSession] = None
81
- self._exit_stack = asyncio.new_event_loop().run_until_complete(self._init_stack())
82
- self.available_tools_llm: List[Dict[str, Any]] = []
83
- self.tool_map: Dict[str, Callable] = {} # Map of tool names to functions
84
- self.server_script_path: Optional[str] = None
85
-
86
- async def _init_stack(self):
87
- return AsyncExitStack()
88
-
89
- def _log(self, message: str, color: str = "cyan") -> None:
90
- if self.debug:
91
- cprint(f"[MCP Client] {message}", color, file=sys.stderr)
92
-
93
- async def _connect_async(self, server_script_path: str) -> None:
94
- self._log(f"Attempting async connection to MCP server: {server_script_path}")
95
- self.server_script_path = server_script_path
96
- command_parts = []
97
- abs_server_script_path = os.path.abspath(server_script_path)
98
- if not os.path.exists(abs_server_script_path):
99
- raise FileNotFoundError(f"MCP server script not found: {abs_server_script_path}")
100
-
101
- if abs_server_script_path.endswith('.py'):
102
- command_parts = [sys.executable, abs_server_script_path]
103
- elif abs_server_script_path.endswith('.js'):
104
- command_parts = ["node", abs_server_script_path]
105
- elif os.access(abs_server_script_path, os.X_OK):
106
- command_parts = [abs_server_script_path]
107
- else:
108
- raise ValueError(f"Unsupported MCP server script type or not executable: {abs_server_script_path}")
109
-
110
- server_params = StdioServerParameters(command=command_parts[0], args=command_parts[1:], env=os.environ.copy())
111
- if self.session:
112
- await self._exit_stack.aclose()
113
- self._exit_stack = AsyncExitStack()
114
-
115
- stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
116
- read, write = stdio_transport
117
- self.session = await self._exit_stack.enter_async_context(ClientSession(read, write))
118
- await self.session.initialize()
119
-
120
- # Get available tools from MCP server
121
- response = await self.session.list_tools()
122
- raw_tools_from_server = response.tools
123
-
124
- # Reset our tool containers
125
- self.available_tools_llm = []
126
- self.tool_map = {}
127
-
128
- # Process MCP tools
129
- if raw_tools_from_server:
130
- for mcp_tool_obj in raw_tools_from_server:
131
- parameters_schema = getattr(mcp_tool_obj, "inputSchema", None)
132
- if parameters_schema is None:
133
- parameters_schema = {"type": "object", "properties": {}}
134
-
135
- tool_name = mcp_tool_obj.name
136
-
137
- # Create tool definition for LLM
138
- tool_definition_for_llm = {
139
- "type": "function",
140
- "function": {
141
- "name": tool_name,
142
- "description": mcp_tool_obj.description or f"MCP tool named {tool_name}",
143
- "parameters": parameters_schema
144
- }
145
- }
146
- self.available_tools_llm.append(tool_definition_for_llm)
147
-
148
- # Create a tool execution function and add to the tool map
149
- # Use a closure to capture the correct tool name for each function
150
- def make_tool_executor(t_name):
151
- return lambda *args, **kwargs: self.execute_tool(t_name, kwargs if kwargs else args[0] if args else {})
152
-
153
-
154
- self.tool_map[tool_name] = make_tool_executor(tool_name)
155
-
156
- tool_names_for_log = [t['function']['name'] for t in self.available_tools_llm]
157
- self._log(f"MCP Connection successful. Discovered and added {len(tool_names_for_log)} tools: {', '.join(tool_names_for_log) if tool_names_for_log else 'None'}")
158
-
159
- def execute_tool(self, tool_name: str, args):
160
- """Execute an MCP tool using the session."""
161
- if not self.session:
162
- return {"error": "No active MCP session"}
163
-
164
- # Ensure args is a dictionary
165
- if not isinstance(args, dict):
166
- args = args if isinstance(args, dict) else {}
167
-
168
- # Special handling for lookup_provider - this is a built-in function, not an MCP tool
169
- if tool_name == 'lookup_provider':
170
- # Import the actual lookup_provider function
171
- try:
172
- # Call the actual function directly
173
- model_name = args.get('model')
174
- if model_name:
175
- result = lookup_provider(model_name)
176
- return {"provider": result, "model": model_name}
177
- else:
178
- return {"error": "Model name not provided for lookup_provider"}
179
- except Exception as e:
180
- return {"error": f"Error executing lookup_provider: {str(e)}"}
181
-
182
- # For actual MCP tools, use the correct API
183
- async def _execute_async():
184
- try:
185
- # The correct method to call an MCP tool might be different
186
- # It could be something like:
187
- result = await self.session.call_tool(tool_name, args)
188
- # Or:
189
- # result = await self.session.invoke_tool(tool_name, **args)
190
- # Check your MCP client API documentation
191
- return result
192
- except Exception as e:
193
- return {"error": f"Error executing MCP tool {tool_name}: {str(e)}"}
194
-
195
- # Run the async function
196
- loop = asyncio.get_event_loop_policy().get_event_loop()
197
- if loop.is_closed():
198
- loop = asyncio.new_event_loop()
199
- asyncio.set_event_loop(loop)
200
- return loop.run_until_complete(_execute_async())
201
-
202
- def connect_to_server_sync(self, server_script_path: str) -> bool:
203
- loop = asyncio.get_event_loop_policy().get_event_loop()
204
- if loop.is_closed():
205
- loop = asyncio.new_event_loop()
206
- asyncio.set_event_loop(loop)
207
- loop.run_until_complete(self._connect_async(server_script_path))
208
- return True
209
-
210
- async def _disconnect_async(self):
211
- if self.session:
212
- self._log("Disconnecting MCP session.")
213
- await self._exit_stack.aclose()
214
- self.session = None
215
- self.available_tools_llm = []
216
- self.tool_map = {}
217
- self.server_script_path = None
218
- else:
219
- self._log("No active MCP session to disconnect.", "yellow")
220
-
221
- def disconnect_sync(self):
222
- loop = asyncio.get_event_loop_policy().get_event_loop()
223
- if loop.is_closed():
224
- loop = asyncio.new_event_loop()
225
- asyncio.set_event_loop(loop)
226
- loop.run_until_complete(self._disconnect_async())
227
- self.session = None
228
- self.available_tools_llm = []
229
- self.tool_map = {}
230
-
231
- def readline_safe_prompt(prompt: str) -> str:
232
- ansi_escape = re.compile(r"(\033\[[0-9;]*[a-zA-Z])")
233
- return ansi_escape.sub(r"\001\1\002", prompt)
234
-
235
- def print_tools_info(tools_list: List[Dict[str, Any]]):
236
- output = "Available tools:\n"
237
- for tool_item in tools_list:
238
- name = "UnknownTool"
239
- description = "No description"
240
- if isinstance(tool_item, dict):
241
- if 'function' in tool_item and 'name' in tool_item['function']:
242
- name = tool_item['function']['name']
243
- if 'function' in tool_item and 'description' in tool_item['function']:
244
- description = tool_item['function']['description']
245
- output += f" {name}\n Description: {description}\n"
246
- return output
247
-
248
- def open_terminal_editor(command: str) -> str:
249
- os.system(command)
250
- return 'Terminal editor closed.'
251
-
252
- def get_multiline_input(prompt: str) -> str:
253
- lines = []
254
- current_prompt = prompt
255
- while True:
256
- line = input(current_prompt)
257
- if line.endswith("\\"):
258
- lines.append(line[:-1])
259
- current_prompt = readline_safe_prompt("> ")
260
- else:
261
- lines.append(line)
262
- break
263
- return "\n".join(lines)
264
-
265
- def split_by_pipes(command: str) -> List[str]:
266
- parts = []
267
- current = ""
268
- in_single_quote = False
269
- in_double_quote = False
270
- escape = False
271
- for char in command:
272
- if escape:
273
- current += char
274
- escape = False
275
- elif char == '\\':
276
- escape = True
277
- current += char
278
- elif char == "'" and not in_double_quote:
279
- in_single_quote = not in_single_quote
280
- current += char
281
- elif char == '"' and not in_single_quote:
282
- in_double_quote = not in_double_quote
283
- current += char
284
- elif char == '|' and not in_single_quote and not in_double_quote:
285
- parts.append(current.strip())
286
- current = ""
287
- else:
288
- current += char
289
- if current:
290
- parts.append(current.strip())
291
- return parts
292
-
293
- def parse_command_safely(cmd: str) -> List[str]:
294
- return shlex.split(cmd)
295
-
296
- def get_file_color(filepath: str) -> tuple:
297
- if not os.path.exists(filepath):
298
- return "grey", []
299
- if os.path.isdir(filepath):
300
- return "blue", ["bold"]
301
- if os.access(filepath, os.X_OK) and not os.path.isdir(filepath):
302
- return "green", ["bold"]
303
- if filepath.endswith((".zip", ".tar", ".gz", ".bz2", ".xz", ".7z")):
304
- return "red", []
305
- if filepath.endswith((".py", ".pyw")):
306
- return "yellow", []
307
- return "white", []
308
-
309
- def format_file_listing(output: str) -> str:
310
- colored_lines = []
311
- current_dir = os.getcwd()
312
- for line in output.strip().split("\n"):
313
- parts = line.split()
314
- if not parts:
315
- colored_lines.append(line)
316
- continue
317
- filepath_guess = parts[-1]
318
- potential_path = os.path.join(current_dir, filepath_guess)
319
- color, attrs = get_file_color(potential_path)
320
- colored_filepath = colored(filepath_guess, color, attrs=attrs)
321
- if len(parts) > 1 :
322
- colored_line = " ".join(parts[:-1] + [colored_filepath])
323
- else:
324
- colored_line = colored_filepath
325
- colored_lines.append(colored_line)
326
- return "\n".join(colored_lines)
327
-
328
- def wrap_text(text: str, width: int = 80) -> str:
329
- lines = []
330
- for paragraph in text.split("\n"):
331
- if len(paragraph) > width:
332
- lines.extend(textwrap.wrap(paragraph, width=width, replace_whitespace=False, drop_whitespace=False))
333
- else:
334
- lines.append(paragraph)
335
- return "\n".join(lines)
336
-
337
- def setup_readline_completer() -> str:
338
- readline.read_history_file(READLINE_HISTORY_FILE)
339
- readline.set_history_length(1000)
340
- readline.parse_and_bind("set enable-bracketed-paste on")
341
- readline.parse_and_bind(r'"\e[A": history-search-backward')
342
- readline.parse_and_bind(r'"\e[B": history-search-forward')
343
- if sys.platform == "darwin":
344
- readline.parse_and_bind("bind ^I rl_complete")
345
- else:
346
- readline.parse_and_bind("tab: complete")
347
- return READLINE_HISTORY_FILE
348
-
349
- def save_readline_history_on_exit():
350
- readline.write_history_file(READLINE_HISTORY_FILE)
351
-
352
- valid_commands_list_global = list(router.routes.keys()) + list(interactive_commands.keys()) + ["cd", "exit", "quit"] + BASH_COMMANDS
353
-
354
- def completer_func(text: str, state_idx: int) -> Optional[str]:
355
- buffer = readline.get_line_buffer()
356
- line_parts = parse_command_safely(buffer)
357
- is_command_start = not line_parts or (len(line_parts) == 1 and not buffer.endswith(' '))
358
- if is_command_start and not text.startswith('-'):
359
- cmd_matches = [cmd + ' ' for cmd in valid_commands_list_global if cmd.startswith(text)]
360
- if state_idx < len(cmd_matches):
361
- return cmd_matches[state_idx]
362
- else:
363
- return None
364
- if text and (not text.startswith('/') or os.path.exists(os.path.dirname(text))):
365
- basedir = os.path.dirname(text)
366
- prefix = os.path.basename(text)
367
- search_dir = basedir if basedir else '.'
368
- matches = [os.path.join(basedir, f) + ('/' if os.path.isdir(os.path.join(search_dir, f)) else ' ')
369
- for f in os.listdir(search_dir) if f.startswith(prefix)]
370
- if state_idx < len(matches):
371
- return matches[state_idx]
372
- else:
373
- return None
374
- return None
375
-
376
- def store_command_embeddings(command: str, output: Any, state: ShellState):
377
- if not chroma_client or not state.embedding_model or not state.embedding_provider:
378
- return
379
- if not command and not output:
380
- return
381
- output_str = str(output) if output else ""
382
- if not command and not output_str:
383
- return
384
- texts_to_embed = [command, output_str]
385
- embeddings = get_embeddings(texts_to_embed, state.embedding_model, state.embedding_provider)
386
- if not embeddings or len(embeddings) != 2:
387
- return
388
- timestamp = datetime.now().isoformat()
389
- npc_name = state.npc.name if isinstance(state.npc, NPC) else str(state.npc)
390
- metadata = [{"type": "command", "timestamp": timestamp, "path": state.current_path, "npc": npc_name, "conversation_id": state.conversation_id},
391
- {"type": "response", "timestamp": timestamp, "path": state.current_path, "npc": npc_name, "conversation_id": state.conversation_id}]
392
- collection_name = f"{state.embedding_provider}_{state.embedding_model}_embeddings"
393
- collection = chroma_client.get_or_create_collection(collection_name)
394
- ids = [f"cmd_{timestamp}_{hash(command)}", f"resp_{timestamp}_{hash(output_str)}"]
395
- collection.add(embeddings=embeddings, documents=texts_to_embed, metadatas=metadata, ids=ids)
396
-
397
- def handle_interactive_command(cmd_parts: List[str], state: ShellState) -> Tuple[ShellState, str]:
398
- command_name = cmd_parts[0]
399
- print(f"Starting interactive {command_name} session...", file=sys.stderr)
400
- return_code = start_interactive_session(interactive_commands[command_name], cmd_parts[1:])
401
- output = f"Interactive {command_name} session ended with code {return_code}"
402
- return state, output
403
-
404
- def handle_cd_command(cmd_parts: List[str], state: ShellState) -> Tuple[ShellState, str]:
405
- target_path = cmd_parts[1] if len(cmd_parts) > 1 else os.path.expanduser("~")
406
- os.chdir(target_path)
407
- state.current_path = os.getcwd()
408
- output = f"Changed directory to {state.current_path}"
409
- return state, output
410
-
411
- def handle_bash_command(cmd_parts: List[str], cmd_str: str, stdin_input: Optional[str], state: ShellState) -> Tuple[ShellState, str]:
412
- command_name = cmd_parts[0]
413
- if command_name in TERMINAL_EDITORS:
414
- return state, open_terminal_editor(cmd_str)
415
-
416
- process = subprocess.Popen(
417
- cmd_parts,
418
- stdin=subprocess.PIPE if stdin_input is not None else None,
419
- stdout=subprocess.PIPE,
420
- stderr=subprocess.PIPE,
421
- text=True,
422
- cwd=state.current_path
423
- )
424
- stdout, stderr = process.communicate(input=stdin_input)
425
-
426
- if process.returncode != 0:
427
- err_msg = stderr.strip() if stderr else f"Command '{cmd_str}' failed (code {process.returncode})."
428
- return state, (stdout.strip() + ("\n" + colored(f"stderr: {err_msg}", "red") if err_msg else "")).strip()
429
-
430
- output = stdout.strip() if stdout else ""
431
- if stderr:
432
- print(colored(f"stderr: {stderr.strip()}", "yellow"), file=sys.stderr)
433
- if command_name in ["ls", "find", "dir"]:
434
- output = format_file_listing(output)
435
- elif not output and process.returncode == 0 and not stderr:
436
- output = ""
437
- return state, output
438
-
439
- def execute_slash_command_local(command: str, stdin_input: Optional[str], state: ShellState, stream: bool) -> Tuple[ShellState, Any]:
440
- command_parts = command.split()
441
- command_name = command_parts[0].lstrip('/')
442
-
443
- handler = router.get_route(command_name)
444
- if handler:
445
- handler_kwargs = {'stream': stream, 'npc': state.npc, 'team': state.team, 'messages': state.messages,
446
- 'model': state.chat_model, 'provider': state.chat_provider, 'api_url': state.api_url, 'api_key': state.api_key}
447
- if stdin_input is not None:
448
- handler_kwargs['stdin_input'] = stdin_input
449
- result_dict = handler(command, **handler_kwargs)
450
- if isinstance(result_dict, dict):
451
- output = result_dict.get("output") or result_dict.get("response")
452
- state.messages = result_dict.get("messages", state.messages)
453
- return state, output
454
- else:
455
- return state, result_dict
456
-
457
- active_npc = state.npc if isinstance(state.npc, NPC) else None
458
- if active_npc and command_name in active_npc.tools_dict:
459
- return state, active_npc.tools_dict[command_name].run(*(command_parts[1:]), state=state, stdin_input=stdin_input, messages=state.messages)
460
-
461
- if state.team and command_name in state.team.tools_dict:
462
- return state, state.team.tools_dict[command_name].run(*(command_parts[1:]), state=state, stdin_input=stdin_input, messages=state.messages)
463
-
464
- if state.team and command_name in state.team.npcs:
465
- state.npc = state.team.npcs[command_name]
466
- return state, f"Switched to NPC: {state.npc.name}"
467
-
468
- return state, colored(f"Unknown slash command or tool: {command_name}", "red")
469
-
470
- def process_pipeline_command(cmd_segment: str, stdin_input: Optional[str], state: ShellState, stream_final: bool) -> Tuple[ShellState, Any]:
471
- if not cmd_segment:
472
- return state, stdin_input
473
-
474
- available_models_all = get_locally_available_models(state.current_path)
475
- model_override, provider_override, cmd_cleaned = get_model_and_provider(cmd_segment, [i for _, i in available_models_all.items()])
476
- cmd_to_process = cmd_cleaned.strip()
477
- if not cmd_to_process:
478
- return state, stdin_input
479
-
480
- exec_model = model_override or state.chat_model
481
- exec_provider = provider_override or state.chat_provider
482
-
483
- if cmd_to_process.startswith("/"):
484
- return execute_slash_command_local(cmd_to_process, stdin_input, state, stream_final)
485
-
486
- cmd_parts = parse_command_safely(cmd_to_process)
487
- if not cmd_parts:
488
- return state, stdin_input
489
- command_name = cmd_parts[0]
490
-
491
- if command_name in interactive_commands:
492
- return handle_interactive_command(cmd_parts, state)
493
-
494
- if command_name == "cd":
495
- return handle_cd_command(cmd_parts, state)
496
-
497
- if command_name in BASH_COMMANDS:
498
- return handle_bash_command(cmd_parts, cmd_to_process, stdin_input, state)
499
- else:
500
- full_llm_cmd = f"{cmd_to_process} {stdin_input}" if stdin_input else cmd_to_process
501
-
502
- mcp_tools_for_upstream = None
503
- if hasattr(state, 'mcp_client') and state.mcp_client and state.mcp_client.available_tools_llm:
504
- mcp_tools_for_upstream = state.mcp_client.available_tools_llm
505
- if hasattr(state.mcp_client, 'tool_map'):
506
- mcp_tool_map = state.mcp_client.tool_map
507
-
508
- result_dict = check_llm_command(
509
- command=full_llm_cmd,
510
- model=exec_model,
511
- provider=exec_provider,
512
- api_url=state.api_url,
513
- api_key=state.api_key,
514
- npc=state.npc,
515
- team=state.team,
516
- messages=state.messages,
517
- images=state.attachments,
518
- stream=stream_final,
519
- tools=mcp_tools_for_upstream,
520
- tool_map=mcp_tool_map,
521
-
522
- context=state
523
- )
524
- state.messages = result_dict.get("messages", state.messages)
525
- return state, result_dict.get("output")
526
-
527
- def check_mode_switch(command:str , state: ShellState) -> Tuple[bool, ShellState]:
528
- if command in ['/cmd', '/agent', '/chat', '/ride']:
529
- state.current_mode = command[1:]
530
- return True, state
531
- return False, state
532
-
533
- def execute_command(command: str, state: ShellState) -> Tuple[ShellState, Any]:
534
- print(f'execute_command {command} {state.current_mode}', file=sys.stderr)
535
- if not command.strip():
536
- return state, ""
537
-
538
- mode_change, state = check_mode_switch(command, state)
539
- if mode_change:
540
- return state, f'Mode changed to {state.current_mode}.'
541
-
542
- original_command_for_embedding = command
543
-
544
- if state.current_mode == 'agent':
545
- commands = split_by_pipes(command)
546
- stdin_for_next = None
547
- final_output = None
548
- current_state = state
549
- for i, cmd_segment in enumerate(commands):
550
- is_last_command = (i == len(commands) - 1)
551
- stream_this_segment = is_last_command and state.stream_output
552
- current_state, output = process_pipeline_command(cmd_segment.strip(), stdin_for_next, current_state, stream_this_segment)
553
- if is_last_command:
554
- final_output = output
555
- if isinstance(output, str):
556
- stdin_for_next = output
557
- elif isgenerator(output):
558
- if not stream_this_segment:
559
- full_stream_output = "".join(map(str, output))
560
- stdin_for_next = full_stream_output
561
- if is_last_command:
562
- final_output = full_stream_output
563
- else:
564
- stdin_for_next = None
565
- final_output = output
566
- elif output is not None:
567
- stdin_for_next = str(output)
568
- else:
569
- stdin_for_next = None
570
- if final_output is not None and not (isgenerator(final_output) and current_state.stream_output):
571
- store_command_embeddings(original_command_for_embedding, final_output, current_state)
572
- return current_state, final_output
573
-
574
- elif state.current_mode == 'chat':
575
- chat_messages = state.messages + [{"role": "user", "content": command}]
576
- provider_to_use = state.chat_provider
577
- if not provider_to_use and state.chat_model:
578
- from npcpy.npc_sysenv import lookup_provider
579
- provider_to_use = lookup_provider(state.chat_model)
580
-
581
- if provider_to_use == "ollama":
582
- response_dict = get_ollama_response(model=state.chat_model, messages=chat_messages, npc=state.npc, stream=state.stream_output, api_url=state.api_url)
583
- else:
584
- response_dict = get_litellm_response(model=state.chat_model, provider=provider_to_use, messages=chat_messages, npc=state.npc, stream=state.stream_output, api_key=state.api_key, api_url=state.api_url)
585
-
586
- state.messages = response_dict.get("messages", state.messages)
587
- return state, response_dict.get("response")
588
-
589
- elif state.current_mode == 'cmd':
590
- mcp_tools_for_upstream = None
591
- if hasattr(state, 'mcp_client') and state.mcp_client and state.mcp_client.available_tools_llm:
592
- mcp_tools_for_upstream = state.mcp_client.available_tools_llm
593
-
594
- result_dict = check_llm_command(
595
- command=command,
596
- model=state.chat_model,
597
- provider=state.chat_provider,
598
- api_url=state.api_url,
599
- api_key=state.api_key,
600
- npc=state.npc,
601
- team=state.team,
602
- messages=state.messages,
603
- images=state.attachments,
604
- stream=state.stream_output,
605
- tools=mcp_tools_for_upstream,
606
- context=state
607
- )
608
- state.messages = result_dict.get("messages", state.messages)
609
- return state, result_dict.get("output")
610
-
611
- elif state.current_mode == 'ride':
612
- print('Ride mode to be implemented soon', file=sys.stderr)
613
- return state, None
614
-
615
- return state, "Unknown execution mode."
616
-
617
- def check_deprecation_warnings():
618
- if os.getenv("NPCSH_MODEL"):
619
- cprint("Deprecation Warning: NPCSH_MODEL/PROVIDER deprecated. Use NPCSH_CHAT_MODEL/PROVIDER.", "yellow", file=sys.stderr)
620
-
621
- def print_welcome_message():
622
- print("""
623
- Welcome to \033[1;94mnpc\033[0m\033[1;38;5;202msh\033[0m (MCP Edition)!
624
- \033[1;94m \033[0m\033[1;38;5;202m \\\\
625
- \033[1;94m _ __ _ __ ___ \033[0m\033[1;38;5;202m ___ | |___ \\\\
626
- \033[1;94m| '_ \\ | '_ \\ / __|\033[0m\033[1;38;5;202m/ __/ | |_ _| \\\\
627
- \033[1;94m| | | || |_) |( |__ \033[0m\033[1;38;5;202m\_ \ | | | | //
628
- \033[1;94m|_| |_|| .__/ \___|\033[0m\033[1;38;5;202m|___/ |_| |_| //
629
- \033[1;94m| | \033[0m\033[1;38;5;202m //
630
- \033[1;94m| |
631
- \033[1;94m|_|
632
-
633
- Type '/help' for commands. MCP server might be auto-connected.
634
- """)
635
-
636
- def setup_shell_mcp(cli_args: argparse.Namespace, shell_initial_state: ShellState) -> Tuple[CommandHistory, ShellState]:
637
- check_deprecation_warnings()
638
- setup_npcsh_config()
639
-
640
- db_path = os.getenv("NPCSH_DB_PATH", HISTORY_DB_DEFAULT_PATH)
641
- db_path = os.path.expanduser(db_path)
642
- os.makedirs(os.path.dirname(db_path), exist_ok=True)
643
- command_history = CommandHistory(db_path)
644
-
645
- readline.set_completer(completer_func)
646
- setup_readline_completer()
647
- atexit.register(save_readline_history_on_exit)
648
- atexit.register(command_history.close)
649
-
650
- npc_directory = PROJECT_NPC_TEAM_PATH if os.path.exists(PROJECT_NPC_TEAM_PATH) else DEFAULT_NPC_TEAM_PATH
651
- os.makedirs(npc_directory, exist_ok=True)
652
-
653
- if not is_npcsh_initialized():
654
- print("Initializing NPCSH...", file=sys.stderr)
655
- initialize_base_npcs_if_needed(db_path)
656
- print("NPCSH initialization complete. Restart or source ~/.npcshrc.", file=sys.stderr)
657
-
658
- current_shell_state = shell_initial_state
659
-
660
- team = Team(team_path=npc_directory)
661
- current_shell_state.team = team
662
-
663
- default_npc_name = getattr(team, 'default_npc_name', None)
664
- if default_npc_name and default_npc_name in team.npcs:
665
- current_shell_state.npc = team.npcs[default_npc_name]
666
- elif team.npcs:
667
- current_shell_state.npc = next(iter(team.npcs.values()))
668
- cprint(f"No default NPC in team, using '{current_shell_state.npc.name}'.", "yellow", file=sys.stderr)
669
- else:
670
- sibiji_path = os.path.join(DEFAULT_NPC_TEAM_PATH, "sibiji.npc")
671
- if os.path.exists(sibiji_path):
672
- current_shell_state.npc = NPC(file=sibiji_path)
673
- else:
674
- cprint(f"Warning: No NPCs in team and 'sibiji.npc' not found.", "red", file=sys.stderr)
675
-
676
- mcp_server_path = cli_args.mcp_server_path or os.getenv("NPCSH_MCP_SERVER_PATH")
677
- if not mcp_server_path and current_shell_state.team:
678
- team_mcp_servers = getattr(current_shell_state.team, 'mcp_servers', [])
679
- default_mcp_name = getattr(current_shell_state.team, 'default_mcp_server_name', None)
680
- processed_team_mcp_servers: List[Dict[str, str]] = []
681
- if isinstance(team_mcp_servers, list):
682
- for item in team_mcp_servers:
683
- if isinstance(item, dict) and 'script_path' in item:
684
- processed_team_mcp_servers.append({'name': item.get('name', os.path.basename(item['script_path'])), 'script_path': item['script_path']})
685
- elif isinstance(item, str):
686
- processed_team_mcp_servers.append({'name': os.path.basename(item), 'script_path': item})
687
-
688
- if default_mcp_name:
689
- for server_info in processed_team_mcp_servers:
690
- if server_info.get('name') == default_mcp_name:
691
- mcp_server_path = server_info.get('script_path')
692
- break
693
- elif len(processed_team_mcp_servers) == 1:
694
- mcp_server_path = processed_team_mcp_servers[0].get('script_path')
695
-
696
- if mcp_server_path and not os.path.isabs(mcp_server_path) and hasattr(current_shell_state.team, 'path') and current_shell_state.team.path:
697
- mcp_server_path = os.path.join(current_shell_state.team.path, mcp_server_path)
698
-
699
- if mcp_server_path:
700
- cprint(f"Attempting to connect to MCP server: {mcp_server_path}", "yellow", file=sys.stderr)
701
- mcp_client = MCPClientNPC()
702
- if mcp_client.connect_to_server_sync(mcp_server_path):
703
- if hasattr(current_shell_state, 'mcp_client'): # Check if the imported ShellState has the attr
704
- current_shell_state.mcp_client = mcp_client
705
- atexit.register(mcp_client.disconnect_sync)
706
- else:
707
- cprint("CRITICAL ERROR: Imported ShellState object from npcpy.modes._state does not have 'mcp_client' attribute.", "red", file=sys.stderr)
708
- cprint("Please add 'mcp_client: Optional[Any] = None' to its definition.", "red", file=sys.stderr)
709
- # Decide if script should exit here if mcp_client cannot be set on state
710
- # For now, it will continue but mcp features might fail later if state.mcp_client is accessed
711
-
712
-
713
- if sys.stdin.isatty():
714
- print_welcome_message()
715
- return command_history, current_shell_state
716
-
717
- def process_result(user_input: str, result_state: ShellState, output: Any, command_history: CommandHistory):
718
- npc_name = result_state.npc.name if isinstance(result_state.npc, NPC) else str(result_state.npc)
719
- team_name = result_state.team.name if isinstance(result_state.team, Team) else str(result_state.team)
720
- save_conversation_message(command_history, result_state.conversation_id, "user", user_input,
721
- wd=result_state.current_path, model=result_state.chat_model, provider=result_state.chat_provider,
722
- npc=npc_name, team=team_name, attachments=result_state.attachments)
723
- result_state.attachments = None
724
- final_output_str = None
725
-
726
- if result_state.stream_output and (isgenerator(output) or hasattr(output, '__aiter__')):
727
- try:
728
- final_output_str = print_and_process_stream_with_markdown(output, result_state.chat_model, result_state.chat_provider)
729
- except AttributeError as e:
730
- if isinstance(output, str):
731
- if len(output) > 0:
732
- final_output_str = output
733
- render_markdown(final_output_str)
734
- elif output is not None:
735
- final_output_str = str(output)
736
- render_markdown(final_output_str)
737
-
738
- if final_output_str is not None:
739
- is_new_assistant_message = not result_state.messages or result_state.messages[-1].get("role") != "assistant"
740
- if is_new_assistant_message:
741
- result_state.messages.append({"role": "assistant", "content": final_output_str})
742
- elif result_state.messages[-1].get("content") is None:
743
- result_state.messages[-1]["content"] = final_output_str
744
-
745
- print()
746
- if final_output_str:
747
- save_conversation_message(command_history, result_state.conversation_id, "assistant", final_output_str,
748
- wd=result_state.current_path, model=result_state.chat_model, provider=result_state.chat_provider,
749
- npc=npc_name, team=team_name)
750
-
751
- def run_repl(command_history: CommandHistory, shell_state_instance: ShellState):
752
- state = shell_state_instance
753
- print(f'Using {state.current_mode} mode. Use /agent, /cmd, or /chat to switch.', file=sys.stderr)
754
- if state.npc:
755
- print(f'Current NPC: {state.npc.name if isinstance(state.npc, NPC) else state.npc}', file=sys.stderr)
756
- if hasattr(state, 'mcp_client') and state.mcp_client and state.mcp_client.server_script_path:
757
- print(f'MCP Client connected to: {state.mcp_client.server_script_path}', file=sys.stderr)
758
- elif hasattr(state, 'mcp_client') and state.mcp_client:
759
- print(f'MCP Client active but not initially connected.', file=sys.stderr)
760
-
761
- while True:
762
- cwd_colored = colored(os.path.basename(state.current_path), "blue")
763
- prompt_npc_part = f":{orange(state.npc.name)}" if isinstance(state.npc, NPC) else (f":{orange(str(state.npc))}" if state.npc else "")
764
- prompt = readline_safe_prompt(f"{cwd_colored}{prompt_npc_part}> ")
765
- user_input = get_multiline_input(prompt).strip()
766
- if not user_input:
767
- continue
768
- if user_input.lower() in ["exit", "quit"]:
769
- print("Goodbye!", file=sys.stderr)
770
- break
771
- state.current_path = os.getcwd()
772
- state, output = execute_command(user_input, state)
773
- process_result(user_input, state, output, command_history)
774
-
775
- def run_non_interactive(command_history: CommandHistory, shell_state_instance: ShellState):
776
- state = shell_state_instance
777
- for line in sys.stdin:
778
- user_input = line.strip()
779
- if not user_input:
780
- continue
781
- if user_input.lower() in ["exit", "quit"]:
782
- break
783
- state.current_path = os.getcwd()
784
- state, output = execute_command(user_input, state)
785
- if state.stream_output and (isgenerator(output) or hasattr(output, '__aiter__')):
786
- for chunk in output:
787
- print(str(chunk), end='')
788
- print()
789
- elif output is not None:
790
- print(output)
791
-
792
- def main() -> None:
793
- parser = argparse.ArgumentParser(description="npcsh (MCP Edition) - An NPC-powered shell with MCP.")
794
- parser.add_argument("-v", "--version", action="version", version=f"npcsh_mcp version {VERSION}")
795
- parser.add_argument("-c", "--command", type=str, help="Execute a single command and exit.")
796
- parser.add_argument("--mcp-server-path", type=str, help="Path to an MCP server script to connect to.")
797
- args = parser.parse_args()
798
-
799
- command_history, shell_state_instance = setup_shell_mcp(args, initial_state)
800
-
801
- if args.command:
802
- state_for_cmd = shell_state_instance
803
- state_for_cmd.current_path = os.getcwd()
804
- final_state, output = execute_command(args.command, state_for_cmd)
805
- if final_state.stream_output and (isgenerator(output) or hasattr(output, '__aiter__')):
806
- for chunk in output:
807
- print(str(chunk), end='')
808
- print()
809
- elif output is not None:
810
- print(output)
811
- elif not sys.stdin.isatty():
812
- run_non_interactive(command_history, shell_state_instance)
813
- else:
814
- try:
815
- run_repl(command_history, shell_state_instance)
816
- except KeyboardInterrupt:
817
- print("\nnpcsh terminated by user (KeyboardInterrupt).", file=sys.stderr)
818
- except EOFError:
819
- print("\nnpcsh terminated (EOF).", file=sys.stderr)
820
-
821
- if __name__ == "__main__":
822
- main()