bone-agent 1.3.0

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,215 @@
1
+ """Interactive tool confirmation panel with arrow key navigation."""
2
+
3
+ import asyncio
4
+ from threading import Timer
5
+ from typing import Optional, Tuple
6
+ from prompt_toolkit import HTML
7
+ from prompt_toolkit.application import Application
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.keys import Keys
10
+ from prompt_toolkit.layout import Layout, HSplit, Window
11
+ from prompt_toolkit.layout.controls import FormattedTextControl
12
+
13
+
14
+
15
+
16
+ class ToolConfirmationPanel:
17
+ """Interactive panel for tool execution confirmation with arrow key navigation."""
18
+
19
+ # Public constants
20
+ SUMMARY_DISPLAY_DELAY = 0.5 # Seconds to show summary before auto-exit
21
+ CURSOR = "> "
22
+ STANDARD_OPTIONS = [
23
+ {"value": "accept", "text": "Accept"},
24
+ {"value": "advise", "text": "Advise"},
25
+ {"value": "cancel", "text": "Cancel"},
26
+ ]
27
+ EDIT_OPTIONS = [
28
+ {"value": "accept", "text": "Accept"},
29
+ {"value": "accept_all_edits", "text": "Accept All Edits"},
30
+ {"value": "advise", "text": "Advise"},
31
+ {"value": "cancel", "text": "Cancel"},
32
+ ]
33
+
34
+ def __init__(self, tool_command: str, reason: Optional[str] = None, is_edit_tool: bool = False, cycle_approve_mode=None):
35
+ """Initialize the tool confirmation panel.
36
+
37
+ Args:
38
+ tool_command: Command/tool being executed
39
+ reason: Optional reason/details about the tool execution
40
+ is_edit_tool: Whether this is an edit tool (shows extra toggle option)
41
+ cycle_approve_mode: Optional callback to cycle approve_mode
42
+ """
43
+ self.tool_command = tool_command
44
+ self.reason = reason
45
+ self.is_edit_tool = is_edit_tool
46
+ self.cycle_approve_mode = cycle_approve_mode
47
+ self.selected_index = 0
48
+ self._showing_summary = False
49
+ self._selected_value = None
50
+ # Use appropriate options based on tool type
51
+ self._options = self.EDIT_OPTIONS if is_edit_tool else self.STANDARD_OPTIONS
52
+
53
+ def _get_display_text(self) -> HTML:
54
+ """Get the formatted text to display.
55
+
56
+ Returns:
57
+ HTML formatted text with current selection state
58
+ """
59
+ lines = []
60
+
61
+ # Check if showing summary
62
+ if self._showing_summary:
63
+ # Find the option text for the selected value
64
+ selected_opt = next((opt for opt in self._options if opt.get("value") == self._selected_value), None)
65
+ selected_text = selected_opt.get("text", self._selected_value) if selected_opt else self._selected_value
66
+
67
+ lines.append("<b>Selection Summary</b>")
68
+ lines.append("")
69
+ lines.append(f"<b>Tool:</b> {self.tool_command}")
70
+ lines.append(f'<style fg="gray"> Selected: {str(selected_text)}</style>')
71
+ lines.append("")
72
+ else:
73
+ # Tool information
74
+ lines.append(f"<b>Tool:</b> {self.tool_command}")
75
+ if self.reason:
76
+ lines.append(f"<b>Reason:</b> {self.reason}")
77
+ lines.append("")
78
+
79
+ # Render options
80
+ for idx, opt in enumerate(self._options):
81
+ text = opt.get("text", "")
82
+
83
+ if idx == self.selected_index:
84
+ # Selected option - show cursor and highlight in bold white
85
+ lines.append(f'<style fg="white" bold="true">{self.CURSOR}{text}</style>')
86
+ else:
87
+ # Unselected option - dark grey
88
+ lines.append(f'<style fg="gray"> {text}</style>')
89
+
90
+ return HTML("\n".join(lines))
91
+
92
+ def _exit_with_summary(self, event, result: str) -> None:
93
+ """Show summary screen and exit application after delay.
94
+
95
+ Args:
96
+ event: PromptToolkit event object
97
+ result: Result value to return when application exits
98
+ """
99
+ if self._showing_summary:
100
+ return
101
+ self._showing_summary = True
102
+ self._selected_value = result
103
+ event.app.invalidate()
104
+ Timer(self.SUMMARY_DISPLAY_DELAY, lambda: event.app.exit(result=result)).start()
105
+
106
+ def _create_key_bindings(self) -> KeyBindings:
107
+ """Create key bindings for navigation and selection.
108
+
109
+ Returns:
110
+ KeyBindings object with Up/Down/Enter/Esc handlers
111
+ """
112
+ bindings = KeyBindings()
113
+
114
+ @bindings.add(Keys.Up)
115
+ def move_up(event):
116
+ """Move selection up."""
117
+ if self.selected_index > 0:
118
+ self.selected_index -= 1
119
+ event.app.invalidate()
120
+
121
+ @bindings.add(Keys.Down)
122
+ def move_down(event):
123
+ """Move selection down."""
124
+ if self.selected_index < len(self._options) - 1:
125
+ self.selected_index += 1
126
+ event.app.invalidate()
127
+
128
+ @bindings.add(Keys.Enter)
129
+ def select(event):
130
+ """Confirm selection."""
131
+ selected_value = self._options[self.selected_index].get("value")
132
+ self._selected_value = selected_value
133
+
134
+ # Handle accept_all_edits option
135
+ if selected_value == "accept_all_edits":
136
+ # Call the callback to cycle approve_mode
137
+ if self.cycle_approve_mode:
138
+ self.cycle_approve_mode()
139
+ # Return "accept" so the current edit proceeds
140
+ self._exit_with_summary(event, "accept")
141
+ else:
142
+ # Show summary then auto-exit
143
+ self._exit_with_summary(event, selected_value)
144
+
145
+ @bindings.add(Keys.Escape)
146
+ def cancel(event):
147
+ """Cancel selection."""
148
+ self._exit_with_summary(event, "cancel")
149
+
150
+ return bindings
151
+
152
+ def _create_layout(self) -> Layout:
153
+ """Create the panel layout.
154
+
155
+ Returns:
156
+ Layout object configured for the confirmation panel
157
+ """
158
+ def get_content():
159
+ return self._get_display_text()
160
+
161
+ content_control = FormattedTextControl(get_content)
162
+
163
+ root_container = HSplit([
164
+ Window(content=content_control, height=None),
165
+ ])
166
+
167
+ return Layout(root_container)
168
+
169
+ def _handle_result(self, result: str) -> Tuple[str, Optional[str]]:
170
+ """Handle the result from the confirmation panel.
171
+
172
+ Args:
173
+ result: The action selected by the user
174
+
175
+ Returns:
176
+ Tuple of (action, guidance_text):
177
+ - action: "accept", "advise", "accept_all_edits", or "cancel"
178
+ - guidance_text: User's advice if action is "advise", None otherwise
179
+ """
180
+ # Handle advise input separately
181
+ if result == "advise":
182
+ from prompt_toolkit import PromptSession
183
+ from prompt_toolkit.formatted_text import HTML
184
+
185
+ guidance_session = PromptSession()
186
+ guidance = guidance_session.prompt(HTML("<b>Enter your advice: </b>")).strip()
187
+ if not guidance:
188
+ # Empty advise treated as cancel
189
+ return ("cancel", None)
190
+ return ("advise", guidance)
191
+
192
+ return (result, None)
193
+
194
+ def run(self) -> Tuple[str, Optional[str]]:
195
+ """Display the confirmation panel and wait for user input.
196
+
197
+ Returns:
198
+ Tuple of (action, guidance_text):
199
+ - action: "accept", "advise", "accept_all_edits", or "cancel"
200
+ - guidance_text: User's advice if action is "advise", None otherwise
201
+ """
202
+ # Create and run the application
203
+ bindings = self._create_key_bindings()
204
+ layout = self._create_layout()
205
+
206
+ application = Application(
207
+ layout=layout,
208
+ key_bindings=bindings,
209
+ full_screen=False,
210
+ mouse_support=False,
211
+ )
212
+
213
+ # Use run_async with asyncio to properly await coroutines
214
+ result = asyncio.run(application.run_async())
215
+ return self._handle_result(result)
@@ -0,0 +1 @@
1
+ """Utility modules for bone-agent."""
@@ -0,0 +1,199 @@
1
+ """Citation parser for sub-agent results.
2
+
3
+ Parses bracketed citation patterns from sub-agent output and injects
4
+ the actual file contents. This module is the single source of truth
5
+ for the citation format contract between sub-agent and main agent.
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+
14
+ @dataclass
15
+ class Citation:
16
+ """A parsed file citation from sub-agent output."""
17
+ rel_path: str
18
+ start_line: int
19
+ end_line: Optional[int] = None # None means full file
20
+
21
+
22
+ # Regex to find explicit citation patterns (bracketed notation only for safety)
23
+ CITATION_PATTERN = re.compile(
24
+ r"(?:-\s+\[(.*?)\]\s+\((?:lines\s+)?(\d+)-(\d+)(?:\s*lines)?|full)\)|"
25
+ r"(?:lines\s+(\d+)-(\d+)\s+in\s+\[(.*?)\])|"
26
+ r"(?:\[(.*?)\]:(\d+)-(\d+))|"
27
+ r"(?:\[(.*?)\]:(\d+))|"
28
+ r"(?:\[([^\]]*?/[^\]]*?)\](?![:(]))"
29
+ )
30
+
31
+
32
+ def parse_citations(text: str) -> List[Citation]:
33
+ """Parse bracketed citation patterns from sub-agent output.
34
+
35
+ Supports these formats:
36
+ - [path/to/file] (lines N-M) or (full)
37
+ - lines N-M in [path/to/file]
38
+ - [path/to/file]:N-M
39
+ - [path/to/file]:N
40
+ - [path/to/file] (full file)
41
+
42
+ Args:
43
+ text: Sub-agent result text to parse
44
+
45
+ Returns:
46
+ List of Citation instances found in the text
47
+ """
48
+ citations = []
49
+
50
+ for line in text.split('\n'):
51
+ match = CITATION_PATTERN.search(line)
52
+ if not match:
53
+ continue
54
+
55
+ if match.group(1):
56
+ # Pattern 1: - [file] (N-M) or (full)
57
+ rel_path = match.group(1).strip()
58
+ if match.group(2) and match.group(3):
59
+ start_line = int(match.group(2))
60
+ end_line = int(match.group(3))
61
+ else:
62
+ start_line = 1
63
+ end_line = None
64
+ elif match.group(4) and match.group(5) and match.group(6):
65
+ # Pattern 2: lines N-M in [file]
66
+ start_line = int(match.group(4))
67
+ end_line = int(match.group(5))
68
+ rel_path = match.group(6).strip()
69
+ elif match.group(7) and match.group(8) and match.group(9):
70
+ # Pattern 3: [file]:N-M
71
+ rel_path = match.group(7).strip()
72
+ start_line = int(match.group(8))
73
+ end_line = int(match.group(9))
74
+ elif match.group(10) and match.group(11):
75
+ # Pattern 4: [file]:N (single line)
76
+ rel_path = match.group(10).strip()
77
+ start_line = int(match.group(11))
78
+ end_line = start_line
79
+ elif match.group(12):
80
+ # Pattern 5: [file] (full file)
81
+ rel_path = match.group(12).strip()
82
+ start_line = 1
83
+ end_line = None
84
+ else:
85
+ continue
86
+
87
+ citations.append(Citation(
88
+ rel_path=rel_path,
89
+ start_line=start_line,
90
+ end_line=end_line,
91
+ ))
92
+
93
+ return citations
94
+
95
+
96
+ def _format_header(citation: Citation, lines_read: Optional[int], actual_start_line: Optional[int]) -> str:
97
+ """Format a citation header string for injected content.
98
+
99
+ Args:
100
+ citation: The Citation being formatted
101
+ lines_read: Actual number of lines read (from metadata)
102
+ actual_start_line: Actual start line (from metadata)
103
+
104
+ Returns:
105
+ Formatted header string like "lines 45-78 (34 lines)"
106
+ """
107
+ if lines_read is not None:
108
+ actual_start = actual_start_line or citation.start_line
109
+ if actual_start > 1:
110
+ end = actual_start + lines_read - 1
111
+ else:
112
+ end = lines_read
113
+ line_label = "line" if lines_read == 1 else "lines"
114
+ return f"lines {actual_start}-{end} ({lines_read} {line_label})"
115
+ return "full"
116
+
117
+
118
+ def inject_file_contents(
119
+ raw_result: str,
120
+ repo_root: Path,
121
+ gitignore_spec=None,
122
+ console=None,
123
+ ) -> str:
124
+ """Parse sub-agent result and inject actual file contents.
125
+
126
+ Extracts citations from the sub-agent output, reads the referenced
127
+ files, and appends the content in a structured format that the main
128
+ agent can use directly.
129
+
130
+ Args:
131
+ raw_result: Sub-agent result text containing citations
132
+ repo_root: Repository root directory
133
+ gitignore_spec: PathSpec for .gitignore filtering
134
+ console: Rich console for output (unused, kept for API compat)
135
+
136
+ Returns:
137
+ Combined string with original result + injected file contents,
138
+ or just the original result if no citations were found
139
+ """
140
+ from tools.file_reader import read_file as read_file_with_bypass
141
+ from utils.result_parsers import extract_multiple_metadata
142
+
143
+ citations = parse_citations(raw_result)
144
+ if not citations:
145
+ return raw_result
146
+
147
+ injected_files_content = []
148
+
149
+ for citation in citations:
150
+ max_lines = None
151
+ if citation.end_line is not None:
152
+ max_lines = citation.end_line - citation.start_line + 1
153
+
154
+ try:
155
+ tool_result = read_file_with_bypass(
156
+ citation.rel_path,
157
+ repo_root,
158
+ max_lines=max_lines,
159
+ start_line=citation.start_line,
160
+ gitignore_spec=gitignore_spec,
161
+ )
162
+
163
+ # Check for exit code
164
+ first_line = tool_result.split('\n')[0] if tool_result else ""
165
+ if first_line.startswith("exit_code="):
166
+ exit_code = first_line.split("=")[1].split()[0]
167
+ if exit_code != "0":
168
+ injected_files_content.append(
169
+ f"### {citation.rel_path} (Blocked or unavailable)"
170
+ )
171
+ injected_files_content.append(tool_result.strip())
172
+ injected_files_content.append("")
173
+ continue
174
+
175
+ # Strip metadata line and extract content
176
+ content_lines = tool_result.splitlines()[1:] if isinstance(tool_result, str) else []
177
+ content = "\n".join(content_lines).rstrip()
178
+
179
+ # Parse actual lines_read and start_line from metadata
180
+ metadata = extract_multiple_metadata(tool_result, 'lines_read', 'start_line')
181
+ lines_read = metadata.get('lines_read')
182
+ actual_start = metadata.get('start_line')
183
+
184
+ header_info = _format_header(citation, lines_read, actual_start)
185
+
186
+ injected_files_content.append(f"### {citation.rel_path} ({header_info})")
187
+ injected_files_content.append("```")
188
+ injected_files_content.append(content)
189
+ injected_files_content.append("```\n")
190
+
191
+ except Exception as e:
192
+ injected_files_content.append(
193
+ f"### {citation.rel_path} (Error reading file: {e})"
194
+ )
195
+
196
+ if not injected_files_content:
197
+ return raw_result
198
+
199
+ return raw_result + "\n\n## Injected File Contents\n\n" + "\n".join(injected_files_content)
@@ -0,0 +1,158 @@
1
+ """External editor integration for bone-agent."""
2
+ import os
3
+ import platform
4
+ import subprocess
5
+ import tempfile
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Tuple, Optional
9
+
10
+
11
+ def get_editor() -> str:
12
+ """Get editor command with OS-specific defaults.
13
+
14
+ Priority:
15
+ 1. EDITOR environment variable
16
+ 2. OS defaults: notepad.exe (Windows) | nano/vi/vim (Unix)
17
+
18
+ Returns:
19
+ str: Editor command or path
20
+ """
21
+ # 1. Check environment variable
22
+ editor = os.environ.get("EDITOR")
23
+ if editor and editor.strip():
24
+ return editor.strip()
25
+
26
+ # 2. OS-specific defaults
27
+ if platform.system() == "Windows":
28
+ return "notepad.exe"
29
+ else:
30
+ # Try to find common editors
31
+ for cmd in ["nvim", "nano", "vi", "vim"]:
32
+ if shutil.which(cmd):
33
+ return cmd
34
+ # Fallback to nano even if not found (will error later)
35
+ return "nano"
36
+
37
+
38
+ def _create_temp_file() -> Tuple[Path, object]:
39
+ """Create temporary file for editing.
40
+
41
+ Returns:
42
+ tuple: (Path object, file handle)
43
+ """
44
+ # Create with .md extension for better syntax highlighting
45
+ temp_fd = tempfile.NamedTemporaryFile(
46
+ mode='w+',
47
+ suffix='.md',
48
+ prefix='bone_agent_edit_',
49
+ delete=False,
50
+ encoding='utf-8'
51
+ )
52
+
53
+ temp_fd.flush()
54
+
55
+ return Path(temp_fd.name), temp_fd
56
+
57
+
58
+ def _strip_comment_lines(content: str) -> str:
59
+ """Remove comment lines (starting with #) from content.
60
+
61
+ Args:
62
+ content: Raw content from editor
63
+
64
+ Returns:
65
+ str: Content with comment lines removed
66
+ """
67
+ lines = []
68
+ for line in content.split('\n'):
69
+ stripped = line.strip()
70
+ # Keep empty lines and non-comment lines
71
+ if not stripped.startswith('#'):
72
+ lines.append(line)
73
+
74
+ return '\n'.join(lines).strip()
75
+
76
+
77
+ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
78
+ """Open external editor and return user input.
79
+
80
+ Args:
81
+ console: Rich console for output
82
+ debug_mode: Whether to show debug information
83
+
84
+ Returns:
85
+ tuple: (success: bool, content: str or None)
86
+ - (True, content) if successful
87
+ - (False, None) if failed or cancelled
88
+ """
89
+ editor_cmd = get_editor()
90
+ temp_path = None
91
+
92
+ try:
93
+ # Create temporary file
94
+ temp_path, temp_fd = _create_temp_file()
95
+ temp_fd.close()
96
+
97
+ if debug_mode:
98
+ console.print(f"[dim]Temp file: {temp_path}[/dim]")
99
+ console.print(f"[dim]Editor: {editor_cmd}[/dim]")
100
+
101
+ console.print("[#5F9EA0]Opening editor...[/#5F9EA0]")
102
+ console.print("[dim]Save and close the editor when done[/dim]")
103
+
104
+ # Launch editor and wait for it to close
105
+ # Use shell=True on Windows for notepad, False for better security on Unix
106
+ use_shell = platform.system() == "Windows"
107
+
108
+ result = subprocess.run(
109
+ [editor_cmd, str(temp_path)],
110
+ shell=use_shell,
111
+ check=False # Don't raise on non-zero exit
112
+ )
113
+
114
+ if result.returncode != 0 and debug_mode:
115
+ console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
116
+
117
+ # Read content from temp file
118
+ content = temp_path.read_text(encoding='utf-8')
119
+
120
+ # Strip comment lines
121
+ content = _strip_comment_lines(content)
122
+
123
+ if debug_mode:
124
+ console.print(f"[dim]Read {len(content)} characters[/dim]")
125
+
126
+ return (True, content)
127
+
128
+ except FileNotFoundError:
129
+ console.print(f"[red]Editor '{editor_cmd}' not found[/red]", markup=False)
130
+ console.print("[dim]Set EDITOR as environment variable[/dim]")
131
+ if debug_mode:
132
+ console.print(f"[dim]Tried to run: {editor_cmd}[/dim]")
133
+ return (False, None)
134
+
135
+ except PermissionError as e:
136
+ console.print(f"[red]Permission denied: {e}[/red]", markup=False)
137
+ console.print("[dim]Check permissions on temporary directory[/dim]")
138
+ return (False, None)
139
+
140
+ except Exception as e:
141
+ console.print(f"[red]Failed to open editor: {e}[/red]", markup=False)
142
+ if debug_mode:
143
+ import traceback
144
+ console.print(f"[dim]{traceback.format_exc()}[/dim]")
145
+ return (False, None)
146
+
147
+ finally:
148
+ # Cleanup temp file
149
+ if temp_path and temp_path.exists():
150
+ try:
151
+ temp_path.unlink()
152
+ if debug_mode:
153
+ console.print(f"[dim]Cleaned up temp file[/dim]")
154
+ except Exception as e:
155
+ # Log but don't crash on cleanup failure
156
+ console.print(f"[yellow]Warning: Failed to delete temp file: {e}[/yellow]")
157
+ if debug_mode:
158
+ console.print(f"[dim]Temp file may remain at: {temp_path}[/dim]")