janito 1.2.1__py3-none-any.whl → 1.3.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.
Files changed (43) hide show
  1. janito/__init__.py +1 -1
  2. janito/agent/agent.py +23 -10
  3. janito/agent/config.py +37 -9
  4. janito/agent/conversation.py +8 -0
  5. janito/agent/runtime_config.py +1 -0
  6. janito/agent/tool_handler.py +154 -52
  7. janito/agent/tools/__init__.py +8 -5
  8. janito/agent/tools/ask_user.py +2 -1
  9. janito/agent/tools/fetch_url.py +27 -35
  10. janito/agent/tools/file_ops.py +72 -67
  11. janito/agent/tools/find_files.py +47 -26
  12. janito/agent/tools/get_lines.py +58 -0
  13. janito/agent/tools/py_compile.py +26 -0
  14. janito/agent/tools/python_exec.py +47 -0
  15. janito/agent/tools/remove_directory.py +38 -0
  16. janito/agent/tools/replace_text_in_file.py +67 -0
  17. janito/agent/tools/run_bash_command.py +134 -0
  18. janito/agent/tools/search_files.py +52 -0
  19. janito/cli/_print_config.py +1 -1
  20. janito/cli/arg_parser.py +6 -1
  21. janito/cli/config_commands.py +56 -8
  22. janito/cli/runner.py +21 -9
  23. janito/cli_chat_shell/chat_loop.py +5 -3
  24. janito/cli_chat_shell/commands.py +34 -37
  25. janito/cli_chat_shell/config_shell.py +1 -1
  26. janito/cli_chat_shell/load_prompt.py +1 -1
  27. janito/cli_chat_shell/session_manager.py +11 -15
  28. janito/cli_chat_shell/ui.py +17 -8
  29. janito/render_prompt.py +3 -1
  30. janito/web/app.py +1 -1
  31. janito-1.3.0.dist-info/METADATA +142 -0
  32. janito-1.3.0.dist-info/RECORD +51 -0
  33. janito/agent/tools/bash_exec.py +0 -58
  34. janito/agent/tools/file_str_replace.py +0 -48
  35. janito/agent/tools/search_text.py +0 -41
  36. janito/agent/tools/view_file.py +0 -34
  37. janito/templates/system_instructions.j2 +0 -38
  38. janito-1.2.1.dist-info/METADATA +0 -85
  39. janito-1.2.1.dist-info/RECORD +0 -49
  40. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/WHEEL +0 -0
  41. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/entry_points.txt +0 -0
  42. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/licenses/LICENSE +0 -0
  43. {janito-1.2.1.dist-info → janito-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,67 +1,72 @@
1
- import os
2
- import shutil
3
- from janito.agent.tool_handler import ToolHandler
4
- from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path
5
-
6
- @ToolHandler.register_tool
7
- def create_file(path: str, content: str, overwrite: bool = False) -> str:
8
- if os.path.exists(path):
9
- if os.path.isdir(path):
10
- print_error("❌ Error: is a directory")
11
- return f"❌ Cannot create file: '{path}' is an existing directory."
12
- if not overwrite:
13
- print_error(f"❗ Error: file '{path}' exists and overwrite is False")
14
- return f"❗ Cannot create file: '{path}' already exists and overwrite is False."
15
- print_info(f"📝 Creating file: '{format_path(path)}' ... ")
16
- try:
17
- with open(path, "w", encoding="utf-8") as f:
18
- f.write(content)
19
- print_success(" Success")
20
- return f" Successfully created the file at '{path}'."
21
- except Exception as e:
22
- print_error(f" Error: {e}")
23
- return f" Failed to create the file at '{path}': {e}"
24
-
25
- @ToolHandler.register_tool
26
- def remove_file(path: str) -> str:
27
- print_info(f"🗑️ Removing file: '{format_path(path)}' ... ")
28
- try:
29
- os.remove(path)
30
- print_success(" Success")
31
- return f"✅ Successfully deleted the file at '{path}'."
32
- except Exception as e:
33
- print_error(f"❌ Error: {e}")
34
- return f" Failed to delete the file at '{path}': {e}"
35
-
36
- @ToolHandler.register_tool
37
- def move_file(source_path: str, destination_path: str, overwrite: bool = False) -> str:
38
- print_info(f"🚚 Moving '{format_path(source_path)}' to '{format_path(destination_path)}' ... ")
39
- try:
40
- if not os.path.exists(source_path):
41
- print_error("❌ Error: source does not exist")
42
- return f"❌ Source path '{source_path}' does not exist."
43
- if os.path.exists(destination_path):
44
- if not overwrite:
45
- print_error("❌ Error: destination exists and overwrite is False")
46
- return f" Destination path '{destination_path}' already exists. Use overwrite=True to replace it."
47
- if os.path.isdir(destination_path):
48
- shutil.rmtree(destination_path)
49
- else:
50
- os.remove(destination_path)
51
- shutil.move(source_path, destination_path)
52
- print_success("✅ Success")
53
- return f" Successfully moved '{source_path}' to '{destination_path}'."
54
- except Exception as e:
55
- print_error(f"❌ Error: {e}")
56
- return f"❌ Failed to move '{source_path}' to '{destination_path}': {e}"
57
-
58
- @ToolHandler.register_tool
59
- def create_directory(path: str) -> str:
60
- print_info(f"📁 Creating directory: '{format_path(path)}' ... ")
61
- try:
62
- os.makedirs(path, exist_ok=True)
63
- print_success("✅ Success")
64
- return f"✅ Directory '{path}' created successfully."
65
- except Exception as e:
66
- print_error(f"❌ Error: {e}")
67
- return f"❌ Error creating directory '{path}': {e}"
1
+ import os
2
+ import shutil
3
+ from janito.agent.tool_handler import ToolHandler
4
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path
5
+
6
+ @ToolHandler.register_tool
7
+ def create_file(path: str, content: str, overwrite: bool = False) -> str:
8
+ """
9
+ Create a new file or update an existing file with the given content.
10
+
11
+ Args:
12
+ path (str): Path to the file to create or update.
13
+ content (str): Content to write to the file.
14
+ overwrite (bool): Whether to overwrite the file if it exists.
15
+ """
16
+ updating = os.path.exists(path) and not os.path.isdir(path)
17
+ if os.path.exists(path):
18
+ if os.path.isdir(path):
19
+ print_error(" Error: is a directory")
20
+ return f" Cannot create file: '{path}' is an existing directory."
21
+ if not overwrite:
22
+ print_error(f" Error: file '{path}' exists and overwrite is False")
23
+ return f" Cannot create file: '{path}' already exists and overwrite is False."
24
+ if updating and overwrite:
25
+ print_info(f"📝 Updating file: '{format_path(path)}' ... ")
26
+ else:
27
+ print_info(f"📝 Creating file: '{format_path(path)}' ... ")
28
+ old_lines = None
29
+ if updating and overwrite:
30
+ with open(path, "r", encoding="utf-8") as f:
31
+ old_lines = sum(1 for _ in f)
32
+ with open(path, "w", encoding="utf-8") as f:
33
+ f.write(content)
34
+ print_success(" Success")
35
+ if old_lines is not None:
36
+ new_lines = content.count('\n') + 1 if content else 0
37
+ return f"✅ Successfully updated the file at '{path}' ({old_lines} > {new_lines} lines)."
38
+ else:
39
+ return f"✅ Successfully created the file at '{path}'."
40
+
41
+
42
+ @ToolHandler.register_tool
43
+ def remove_file(path: str) -> str:
44
+ print_info(f"🗑️ Removing file: '{format_path(path)}' ... ")
45
+ os.remove(path)
46
+ print_success(" Success")
47
+ return f"✅ Successfully deleted the file at '{path}'."
48
+
49
+ @ToolHandler.register_tool
50
+ def move_file(source_path: str, destination_path: str, overwrite: bool = False) -> str:
51
+ print_info(f"🚚 Moving '{format_path(source_path)}' to '{format_path(destination_path)}' ... ")
52
+ if not os.path.exists(source_path):
53
+ print_error(" Error: source does not exist")
54
+ return f"❌ Source path '{source_path}' does not exist."
55
+ if os.path.exists(destination_path):
56
+ if not overwrite:
57
+ print_error("❌ Error: destination exists and overwrite is False")
58
+ return f"❌ Destination path '{destination_path}' already exists. Use overwrite=True to replace it."
59
+ if os.path.isdir(destination_path):
60
+ shutil.rmtree(destination_path)
61
+ else:
62
+ os.remove(destination_path)
63
+ shutil.move(source_path, destination_path)
64
+ print_success("✅ Success")
65
+ return f"✅ Successfully moved '{source_path}' to '{destination_path}'."
66
+
67
+ @ToolHandler.register_tool
68
+ def create_directory(path: str) -> str:
69
+ print_info(f"📁 Creating directory: '{format_path(path)}' ... ")
70
+ os.makedirs(path, exist_ok=True)
71
+ print_success("✅ Success")
72
+ return f"✅ Directory '{path}' created successfully."
@@ -1,37 +1,58 @@
1
1
  import os
2
2
  import fnmatch
3
3
  from janito.agent.tool_handler import ToolHandler
4
- from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path, format_number
5
- from janito.agent.tools.gitignore_utils import load_gitignore_patterns, filter_ignored
4
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error
6
5
 
7
6
  @ToolHandler.register_tool
8
- def find_files(directory: str, pattern: str = "*") -> str:
7
+ def find_files(
8
+ directory: str,
9
+ pattern: str,
10
+ recursive: bool = False,
11
+ max_results: int = 100
12
+ ) -> str:
9
13
  """
10
- Recursively find files matching a pattern within a directory, skipping ignored files/dirs.
11
-
12
- directory: The root directory to start searching from.
13
- pattern: Glob pattern to match filenames (default: '*').
14
+ Find files in a directory matching a pattern.
15
+ Args:
16
+ directory (str): The directory to search in.
17
+ pattern (str): The filename pattern to match (e.g., '*.txt').
18
+ recursive (bool): Whether to search subdirectories.
19
+ max_results (int): Maximum number of results to return.
20
+ Returns:
21
+ str: Newline-separated list of matching file paths, with summary and warnings if truncated.
14
22
  """
15
- print_info(f"🔍 Searching for files in '{format_path(directory)}' matching pattern '{pattern}' ... ")
16
-
17
- # Check if pattern is an exact relative path to a file
18
- full_path = os.path.join(directory, pattern)
19
- if os.path.isfile(full_path):
20
- print_success("✅ Found 1 file (exact match)")
21
- return full_path
22
-
23
+ print_info(f"🔍 find_files | Dir: {directory} | Pattern: {pattern} | Recursive: {recursive} | Max: {max_results}")
24
+ # Input validation
25
+ if not os.path.isdir(directory):
26
+ print_error(f"❌ Not a directory: {directory}")
27
+ return ""
28
+ if not isinstance(max_results, int) or max_results <= 0:
29
+ print_error(f"❌ Invalid max_results value: {max_results}")
30
+ return ""
23
31
  matches = []
24
- ignore_patterns = load_gitignore_patterns()
25
32
  try:
26
- for root, dirs, files in os.walk(directory):
27
- dirs, files = filter_ignored(root, dirs, files, ignore_patterns)
28
- for filename in fnmatch.filter(files, pattern):
29
- matches.append(os.path.join(root, filename))
30
- print_success(f"✅ Found {format_number(len(matches))} files")
31
- if matches:
32
- return "\n".join(matches)
33
+ if recursive:
34
+ for root, dirs, files in os.walk(directory):
35
+ for name in files:
36
+ if fnmatch.fnmatch(name, pattern):
37
+ matches.append(os.path.join(root, name))
38
+ if len(matches) >= max_results:
39
+ break
40
+ if len(matches) >= max_results:
41
+ break
33
42
  else:
34
- return "No matching files found."
43
+ for name in os.listdir(directory):
44
+ full_path = os.path.join(directory, name)
45
+ if os.path.isfile(full_path) and fnmatch.fnmatch(name, pattern):
46
+ matches.append(full_path)
47
+ if len(matches) >= max_results:
48
+ break
35
49
  except Exception as e:
36
- print_error(f"❌ Error: {e}")
37
- return f"❌ Failed to search files in '{directory}': {e}"
50
+ print_error(f"❌ Error during file search: {e}")
51
+ return ""
52
+ print_success(f"✅ Found {len(matches)} file(s)")
53
+ result = f"Total files found: {len(matches)}\n"
54
+ result += "\n".join(matches)
55
+ if len(matches) == max_results:
56
+ result += "\n# WARNING: Results truncated at max_results. There may be more matching files."
57
+ return result
58
+
@@ -0,0 +1,58 @@
1
+ import os
2
+ from janito.agent.tool_handler import ToolHandler
3
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path, format_number
4
+
5
+ @ToolHandler.register_tool
6
+ def get_lines(
7
+ file_path: str,
8
+ from_line: int = None,
9
+ to_line: int = None
10
+ ) -> str:
11
+ """
12
+ Get lines from a file, optionally with a summary of lines outside the viewed range.
13
+ Always returns the total number of lines in the file at the top of the output.
14
+
15
+ Parameters:
16
+ - file_path (string): Path to the file.
17
+ - from_line (integer, optional): First line to view (1-indexed). If omitted with to_line, returns all lines.
18
+ - to_line (integer, optional): Last line to view (inclusive, 1-indexed, and cannot be more than 200 lines from from_line).
19
+
20
+ If both from_line and to_line are omitted, returns all lines in the file.
21
+ It is recommended to request at least 100 lines or the full file for more efficient context building.
22
+ """
23
+ if from_line is None and to_line is None:
24
+ print_info(f"📂 get_lines | Path: {format_path(file_path)} | All lines requested")
25
+ else:
26
+ print_info(f"📂 get_lines | Path: {format_path(file_path)} | Lines ({from_line}-{to_line})")
27
+ if not os.path.isfile(file_path):
28
+ print_info(f"ℹ️ File not found: {file_path}")
29
+ return ""
30
+ with open(file_path, "r", encoding="utf-8") as f:
31
+ lines = f.readlines()
32
+ total_lines = len(lines)
33
+ if from_line is None and to_line is None:
34
+ numbered_content = ''.join(f"{i+1}: {line}" for i, line in enumerate(lines))
35
+ print_success(f"✅ Returned all {total_lines} lines")
36
+ return f"Total lines in file: {total_lines}\n" + numbered_content
37
+ # Validate range
38
+ if from_line is None or to_line is None:
39
+ print_error(f"❌ Both from_line and to_line must be provided, or neither.")
40
+ return ""
41
+ if from_line < 1 or to_line < from_line or (to_line - from_line > 200):
42
+ print_error(f"❌ Invalid line range: {from_line}-{to_line} for file with {total_lines} lines.")
43
+ return ""
44
+ if to_line > total_lines:
45
+ to_line = total_lines
46
+ selected = lines[from_line-1:to_line]
47
+ numbered_content = ''.join(f"{i}: {line}" for i, line in zip(range(from_line, to_line+1), selected))
48
+ before = lines[:from_line-1]
49
+ after = lines[to_line:]
50
+ before_summary = f"... {len(before)} lines before ...\n" if before else ""
51
+ after_summary = f"... {len(after)} lines after ...\n" if after else ""
52
+ summary = before_summary + after_summary
53
+ if from_line == 1 and to_line == total_lines:
54
+ print_success(f"✅ Returned all {total_lines} lines")
55
+ else:
56
+ print_success(f"✅ Returned lines {from_line} to {to_line} of {total_lines}")
57
+ total_line_info = f"Total lines in file: {total_lines}\n"
58
+ return total_line_info + summary + numbered_content
@@ -0,0 +1,26 @@
1
+ from janito.agent.tool_handler import ToolHandler
2
+ from janito.agent.tools.rich_utils import print_info
3
+ import py_compile
4
+ from typing import Optional
5
+
6
+ @ToolHandler.register_tool
7
+ def py_compile_file(path: str, doraise: Optional[bool] = True) -> str:
8
+ """
9
+ Validate a Python file by compiling it with py_compile.
10
+ This tool should be used to validate Python files after changes.
11
+
12
+ Args:
13
+ path (str): Path to the Python file to validate.
14
+ doraise (Optional[bool]): If True, raise exceptions on compilation errors. Default is True.
15
+
16
+ Returns:
17
+ str: Success message or error details if compilation fails.
18
+ """
19
+ print_info(f"[py_compile_file] Validating Python file: {path}")
20
+ try:
21
+ py_compile.compile(path, doraise=doraise)
22
+ return f"Validation successful: {path} is a valid Python file."
23
+ except FileNotFoundError:
24
+ return f"Validation failed: File not found: {path}"
25
+ except py_compile.PyCompileError as e:
26
+ return f"Validation failed: {e}"
@@ -0,0 +1,47 @@
1
+ from janito.agent.tool_handler import ToolHandler
2
+ from janito.agent.tools.rich_utils import print_info
3
+ import sys
4
+ import multiprocessing
5
+ import io
6
+ from typing import Callable, Optional
7
+
8
+
9
+ def _run_python_code(code: str, result_queue):
10
+ import traceback
11
+ import contextlib
12
+ stdout = io.StringIO()
13
+ stderr = io.StringIO()
14
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
15
+ exec(code, {'__name__': '__main__'})
16
+ result_queue.put({
17
+ 'stdout': stdout.getvalue(),
18
+ 'stderr': stderr.getvalue(),
19
+ 'returncode': 0
20
+ })
21
+
22
+
23
+ @ToolHandler.register_tool
24
+ def python_exec(code: str, on_progress: Optional[Callable[[dict], None]] = None) -> str:
25
+ """
26
+ Execute Python code in a separate process and capture output.
27
+
28
+ Args:
29
+ code (str): The Python code to execute.
30
+ on_progress (Optional[Callable[[dict], None]]): Optional callback function for streaming progress updates (not used).
31
+
32
+ Returns:
33
+ str: A formatted message string containing stdout, stderr, and return code.
34
+ """
35
+ print_info(f"[python_exec] Executing Python code:")
36
+ print_info(code)
37
+ result_queue = multiprocessing.Queue()
38
+ process = multiprocessing.Process(target=_run_python_code, args=(code, result_queue))
39
+ process.start()
40
+ process.join()
41
+ if not result_queue.empty():
42
+ result = result_queue.get()
43
+ else:
44
+ result = {'stdout': '', 'stderr': 'No result returned from process.', 'returncode': -1}
45
+ print_info(f"[python_exec] Execution completed.")
46
+ print_info(f"[python_exec] Return code: {result['returncode']}")
47
+ return f"stdout:\n{result['stdout']}\nstderr:\n{result['stderr']}\nreturncode: {result['returncode']}"
@@ -0,0 +1,38 @@
1
+ import os
2
+ import shutil
3
+ from janito.agent.tool_handler import ToolHandler
4
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path
5
+
6
+ def _is_dir_empty(path):
7
+ return not any(os.scandir(path))
8
+
9
+ @ToolHandler.register_tool
10
+ def remove_directory(path: str, recursive: bool = False) -> str:
11
+ """
12
+ Remove a directory. If recursive is False and the directory is not empty, return an error.
13
+
14
+ Args:
15
+ path (str): Path to the directory to remove.
16
+ recursive (bool): Whether to remove non-empty directories recursively. Default is False.
17
+ Returns:
18
+ str: Result message.
19
+ """
20
+ if not os.path.exists(path):
21
+ print_error(f"❌ Directory '{path}' does not exist.")
22
+ return f"❌ Directory '{path}' does not exist."
23
+ if not os.path.isdir(path):
24
+ print_error(f"❌ Path '{path}' is not a directory.")
25
+ return f"❌ Path '{path}' is not a directory."
26
+ if recursive:
27
+ print_info(f"🗑️ Recursively removing directory: '{format_path(path)}' ... ")
28
+ shutil.rmtree(path)
29
+ print_success("✅ Success")
30
+ return f"✅ Successfully removed directory and all contents at '{path}'."
31
+ else:
32
+ if not _is_dir_empty(path):
33
+ print_error(f"❌ Directory '{path}' is not empty. Use recursive=True to remove non-empty directories.")
34
+ return f"❌ Directory '{path}' is not empty. Use recursive=True to remove non-empty directories."
35
+ print_info(f"🗑️ Removing empty directory: '{format_path(path)}' ... ")
36
+ os.rmdir(path)
37
+ print_success("✅ Success")
38
+ return f"✅ Successfully removed empty directory at '{path}'."
@@ -0,0 +1,67 @@
1
+ import os
2
+ from janito.agent.tool_handler import ToolHandler
3
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error, format_path
4
+
5
+ @ToolHandler.register_tool
6
+ def replace_text_in_file(file_path: str, search_text: str, replacement_text: str, replace_all: bool = False) -> str:
7
+ """
8
+
9
+ Replace exact occurrences of a given text in a file. The match must be exact, including whitespace and indentation, to avoid breaking file syntax or formatting.
10
+
11
+ Args:
12
+ file_path (str): Path to the plain text file.
13
+ search_text (str): Text to search for (exact match).
14
+ replacement_text (str): Text to replace search_text with.
15
+ replace_all (bool): Whether to replace all occurrences or just the first. Default is False.
16
+ Returns:
17
+ str: Result message.
18
+ """
19
+ search_preview = (search_text[:15] + '...') if len(search_text) > 15 else search_text
20
+ replace_preview = (replacement_text[:15] + '...') if len(replacement_text) > 15 else replacement_text
21
+ replace_all_msg = f" | Replace all: True" if replace_all else ""
22
+ print_info(f"📝 replace_text_in_file | Path: {format_path(file_path)} | Search: '{search_preview}' | Replacement: '{replace_preview}'{replace_all_msg}")
23
+ if not os.path.isfile(file_path):
24
+ print_error(f"❌ File not found: {file_path}")
25
+ return f"❌ Error: File not found: {file_path}"
26
+ try:
27
+ with open(file_path, "r", encoding="utf-8") as f:
28
+ content = f.read()
29
+ except PermissionError:
30
+ print_error(f"❌ Permission denied: {file_path}")
31
+ return f"❌ Error: Permission denied: {file_path}"
32
+ except Exception as e:
33
+ print_error(f"❌ Error reading file: {e}")
34
+ return f"❌ Error reading file: {e}"
35
+
36
+ count = content.count(search_text)
37
+ if count == 0:
38
+ print_info(f"ℹ️ Search text not found in file.")
39
+ return f"ℹ️ No occurrences of search text found in '{file_path}'."
40
+ if replace_all:
41
+ new_content = content.replace(search_text, replacement_text)
42
+ replaced_count = count
43
+ else:
44
+ if count > 1:
45
+ # Find line numbers where search_text appears
46
+ lines = content.splitlines()
47
+ found_lines = [i+1 for i, line in enumerate(lines) if search_text in line]
48
+ preview = search_text[:40] + ('...' if len(search_text) > 40 else '')
49
+ print_error(f"❌ Search text found multiple times ({count}). Please provide a more exact match or set replace_all=True.")
50
+ return (
51
+ f"❌ Error: Search text found {count} times in '{file_path}'. "
52
+ f"Preview: '{preview}'. Found at lines: {found_lines}. "
53
+ f"Please provide a more exact match."
54
+ )
55
+ new_content = content.replace(search_text, replacement_text, 1)
56
+ replaced_count = 1 if count == 1 else 0
57
+ try:
58
+ with open(file_path, "w", encoding="utf-8") as f:
59
+ f.write(new_content)
60
+ except PermissionError:
61
+ print_error(f"❌ Permission denied when writing: {file_path}")
62
+ return f"❌ Error: Permission denied when writing: {file_path}"
63
+ except Exception as e:
64
+ print_error(f"❌ Error writing file: {e}")
65
+ return f"❌ Error writing file: {e}"
66
+ print_success(f"✅ Replaced {replaced_count} occurrence(s) of search text in '{file_path}'.")
67
+ return f"✅ Replaced {replaced_count} occurrence(s) of search text in '{file_path}'."
@@ -0,0 +1,134 @@
1
+ from janito.agent.tool_handler import ToolHandler
2
+ from janito.agent.runtime_config import runtime_config
3
+ from janito.agent.tools.rich_utils import print_info, print_success, print_error
4
+ import subprocess
5
+ import multiprocessing
6
+ from typing import Optional
7
+
8
+ import tempfile
9
+ import os
10
+
11
+ def _run_bash_command(command: str, result_queue: 'multiprocessing.Queue', trust: bool = False):
12
+ import subprocess
13
+ with tempfile.NamedTemporaryFile(delete=False, mode='w+', encoding='utf-8', suffix='.stdout') as stdout_file, \
14
+ tempfile.NamedTemporaryFile(delete=False, mode='w+', encoding='utf-8', suffix='.stderr') as stderr_file:
15
+ process = subprocess.Popen(
16
+ command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace'
17
+ )
18
+ while True:
19
+ stdout_line = process.stdout.readline() if process.stdout else ''
20
+ stderr_line = process.stderr.readline() if process.stderr else ''
21
+ if stdout_line:
22
+ if not trust:
23
+ print(stdout_line, end='')
24
+ stdout_file.write(stdout_line)
25
+ stdout_file.flush()
26
+ if stderr_line:
27
+ if not trust:
28
+ print(stderr_line, end='')
29
+ stderr_file.write(stderr_line)
30
+ stderr_file.flush()
31
+ if not stdout_line and not stderr_line and process.poll() is not None:
32
+ break
33
+ # Capture any remaining output after process ends
34
+ if process.stdout:
35
+ for line in process.stdout:
36
+ if not trust:
37
+ print(line, end='')
38
+ stdout_file.write(line)
39
+ if process.stderr:
40
+ for line in process.stderr:
41
+ if not trust:
42
+ print(line, end='')
43
+ stderr_file.write(line)
44
+ stdout_file_path = stdout_file.name
45
+ stderr_file_path = stderr_file.name
46
+ result_queue.put({
47
+ 'stdout_file': stdout_file_path,
48
+ 'stderr_file': stderr_file_path,
49
+ 'returncode': process.returncode
50
+ })
51
+
52
+
53
+ @ToolHandler.register_tool
54
+ def run_bash_command(command: str, timeout: int = 60, require_confirmation: bool = False) -> str:
55
+ trust = runtime_config.get('trust', False)
56
+ """
57
+ Execute a non-interactive bash command and print output live.
58
+
59
+ If require_confirmation is True, the user will be prompted to confirm execution before running the command.
60
+
61
+ Args:
62
+ command (str): The Bash command to execute.
63
+ timeout (int): Maximum number of seconds to allow the command to run. Default is 60.
64
+
65
+ Returns:
66
+ str: A formatted message string containing stdout, stderr, and return code.
67
+ """
68
+ print_info(f"[run_bash_command] Running: {command}")
69
+ if require_confirmation:
70
+ # Prompt the user for confirmation directly
71
+ resp = input(f"Are you sure you want to run this command?\n\n{command}\n\nType 'yes' to confirm: ")
72
+ if resp.strip().lower() != 'yes':
73
+ print_error("❌ Command not confirmed by user.")
74
+ return "❌ Command not confirmed by user."
75
+ print_info(f"🐛 Running bash command: [bold]{command}[/bold] (timeout: {timeout}s)")
76
+ result_queue = multiprocessing.Queue()
77
+ process = multiprocessing.Process(target=_run_bash_command, args=(command, result_queue, trust))
78
+ process.start()
79
+ process.join(timeout)
80
+ if process.is_alive():
81
+ process.terminate()
82
+ process.join()
83
+ result = {'stdout_file': '', 'stderr_file': '', 'error': f'Process timed out after {timeout} seconds.', 'returncode': -1}
84
+ elif not result_queue.empty():
85
+ result = result_queue.get()
86
+ else:
87
+ result = {'stdout_file': '', 'stderr_file': '', 'error': 'No result returned from process.', 'returncode': -1}
88
+ if trust:
89
+ stdout_lines = 0
90
+ stderr_lines = 0
91
+ try:
92
+ with open(result['stdout_file'], 'r', encoding='utf-8') as f:
93
+ stdout_lines = sum(1 for _ in f)
94
+ except Exception:
95
+ pass
96
+ try:
97
+ with open(result['stderr_file'], 'r', encoding='utf-8') as f:
98
+ stderr_lines = sum(1 for _ in f)
99
+ except Exception:
100
+ pass
101
+ print_success(f"✅ Success (trust mode)\nstdout: {result['stdout_file']} (lines: {stdout_lines})\nstderr: {result['stderr_file']} (lines: {stderr_lines})")
102
+ return (
103
+ f"✅ Bash command executed in trust mode. Output is stored at:\n"
104
+ f"stdout: {result['stdout_file']} (lines: {stdout_lines})\n"
105
+ f"stderr: {result['stderr_file']} (lines: {stderr_lines})\n"
106
+ f"returncode: {result['returncode']}\n"
107
+ "To examine the output, use the file-related tools such as get_lines or search_files on the above files."
108
+ )
109
+ print_info("🐛 Bash command execution completed.")
110
+ print_info(f"Return code: {result['returncode']}")
111
+ if result.get('error'):
112
+ print_error(f"Error: {result['error']}")
113
+ return f"❌ Error: {result['error']}\nreturncode: {result['returncode']}"
114
+ stdout_lines = 0
115
+ stderr_lines = 0
116
+ try:
117
+ with open(result['stdout_file'], 'r', encoding='utf-8') as f:
118
+ stdout_lines = sum(1 for _ in f)
119
+ except Exception:
120
+ pass
121
+ try:
122
+ with open(result['stderr_file'], 'r', encoding='utf-8') as f:
123
+ stderr_lines = sum(1 for _ in f)
124
+ except Exception:
125
+ pass
126
+ print_success(f"✅ Success\nstdout saved to: {result['stdout_file']} (lines: {stdout_lines})\nstderr saved to: {result['stderr_file']} (lines: {stderr_lines})")
127
+ return (
128
+ f"✅ Bash command executed.\n"
129
+ f"stdout saved to: {result['stdout_file']} (lines: {stdout_lines})\n"
130
+ f"stderr saved to: {result['stderr_file']} (lines: {stderr_lines})\n"
131
+ f"returncode: {result['returncode']}\n"
132
+ "\nTo examine the output, use the file-related tools such as get_lines or search_files on the above files."
133
+ )
134
+