bone-agent 1.4.0 → 2.0.1

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 (126) hide show
  1. package/bin/bone.js +39 -0
  2. package/package.json +25 -39
  3. package/LICENSE +0 -21
  4. package/README.md +0 -201
  5. package/bin/npm-wrapper.js +0 -235
  6. package/bin/rg +0 -0
  7. package/bin/rg.exe +0 -0
  8. package/config.yaml.example +0 -144
  9. package/prompts/main/ask_questions.md +0 -31
  10. package/prompts/main/batch_independent_calls.md +0 -5
  11. package/prompts/main/casual_interactions.md +0 -11
  12. package/prompts/main/code_references.md +0 -8
  13. package/prompts/main/communication_style.md +0 -12
  14. package/prompts/main/context_reliability.md +0 -12
  15. package/prompts/main/conversational_tool_calling.md +0 -15
  16. package/prompts/main/dream.md +0 -50
  17. package/prompts/main/editing_pattern.md +0 -13
  18. package/prompts/main/error_handling.md +0 -6
  19. package/prompts/main/exploration_pattern.md +0 -21
  20. package/prompts/main/intro.md +0 -1
  21. package/prompts/main/obsidian.md +0 -16
  22. package/prompts/main/obsidian_project.md +0 -79
  23. package/prompts/main/professional_objectivity.md +0 -3
  24. package/prompts/main/skills.md +0 -3
  25. package/prompts/main/targeted_searching.md +0 -10
  26. package/prompts/main/task_lists_pattern.md +0 -8
  27. package/prompts/main/temp_folder.md +0 -9
  28. package/prompts/main/think_before_acting.md +0 -10
  29. package/prompts/main/tone_and_style.md +0 -4
  30. package/prompts/main/tool_preferences.md +0 -24
  31. package/prompts/main/trust_subagent_context.md +0 -21
  32. package/prompts/main/when_to_use_sub_agent.md +0 -7
  33. package/prompts/micro/ask_questions.md +0 -1
  34. package/prompts/micro/batch_independent_calls.md +0 -1
  35. package/prompts/micro/casual_interactions.md +0 -1
  36. package/prompts/micro/code_references.md +0 -1
  37. package/prompts/micro/communication_style.md +0 -1
  38. package/prompts/micro/context_reliability.md +0 -1
  39. package/prompts/micro/conversational_tool_calling.md +0 -1
  40. package/prompts/micro/editing_pattern.md +0 -1
  41. package/prompts/micro/error_handling.md +0 -1
  42. package/prompts/micro/exploration_pattern.md +0 -1
  43. package/prompts/micro/intro.md +0 -1
  44. package/prompts/micro/obsidian.md +0 -4
  45. package/prompts/micro/obsidian_project.md +0 -5
  46. package/prompts/micro/professional_objectivity.md +0 -1
  47. package/prompts/micro/skills.md +0 -1
  48. package/prompts/micro/targeted_searching.md +0 -1
  49. package/prompts/micro/task_lists_pattern.md +0 -1
  50. package/prompts/micro/temp_folder.md +0 -1
  51. package/prompts/micro/think_before_acting.md +0 -5
  52. package/prompts/micro/tone_and_style.md +0 -1
  53. package/prompts/micro/tool_preferences.md +0 -1
  54. package/prompts/micro/trust_subagent_context.md +0 -1
  55. package/prompts/micro/when_to_use_sub_agent.md +0 -1
  56. package/requirements.txt +0 -9
  57. package/src/__init__.py +0 -11
  58. package/src/core/__init__.py +0 -1
  59. package/src/core/agentic.py +0 -1085
  60. package/src/core/chat_manager.py +0 -1577
  61. package/src/core/config_manager.py +0 -260
  62. package/src/core/cron.py +0 -578
  63. package/src/core/cron_allowlist.py +0 -118
  64. package/src/core/memory.py +0 -145
  65. package/src/core/metadata.py +0 -75
  66. package/src/core/retry.py +0 -71
  67. package/src/core/skills.py +0 -463
  68. package/src/core/sub_agent.py +0 -376
  69. package/src/core/tool_approval.py +0 -220
  70. package/src/core/tool_feedback.py +0 -789
  71. package/src/exceptions.py +0 -79
  72. package/src/llm/__init__.py +0 -1
  73. package/src/llm/client.py +0 -176
  74. package/src/llm/codex_provider.py +0 -350
  75. package/src/llm/config.py +0 -536
  76. package/src/llm/prompts.py +0 -494
  77. package/src/llm/providers.py +0 -438
  78. package/src/llm/streaming.py +0 -163
  79. package/src/llm/token_tracker.py +0 -399
  80. package/src/tools/__init__.py +0 -151
  81. package/src/tools/constants.py +0 -59
  82. package/src/tools/create_file.py +0 -136
  83. package/src/tools/directory.py +0 -389
  84. package/src/tools/edit.py +0 -549
  85. package/src/tools/file_reader.py +0 -322
  86. package/src/tools/helpers/__init__.py +0 -99
  87. package/src/tools/helpers/base.py +0 -599
  88. package/src/tools/helpers/converters.py +0 -44
  89. package/src/tools/helpers/file_helpers.py +0 -189
  90. package/src/tools/helpers/formatters.py +0 -411
  91. package/src/tools/helpers/loader.py +0 -145
  92. package/src/tools/helpers/parallel_executor.py +0 -231
  93. package/src/tools/helpers/path_resolver.py +0 -283
  94. package/src/tools/helpers/plugin_manifest.py +0 -185
  95. package/src/tools/obsidian.py +0 -96
  96. package/src/tools/review_sub_agent.py +0 -190
  97. package/src/tools/rg_search.py +0 -477
  98. package/src/tools/search_plugins.py +0 -177
  99. package/src/tools/select_option.py +0 -600
  100. package/src/tools/shell.py +0 -302
  101. package/src/tools/sub_agent.py +0 -139
  102. package/src/tools/task_list.py +0 -269
  103. package/src/tools/web_search.py +0 -61
  104. package/src/ui/__init__.py +0 -1
  105. package/src/ui/banner.py +0 -87
  106. package/src/ui/commands.py +0 -3131
  107. package/src/ui/displays.py +0 -239
  108. package/src/ui/loader.py +0 -284
  109. package/src/ui/main.py +0 -643
  110. package/src/ui/prompt_utils.py +0 -113
  111. package/src/ui/setting_selector.py +0 -590
  112. package/src/ui/setup_wizard.py +0 -294
  113. package/src/ui/sub_agent_panel.py +0 -234
  114. package/src/ui/tool_confirmation.py +0 -226
  115. package/src/utils/__init__.py +0 -1
  116. package/src/utils/citation_parser.py +0 -199
  117. package/src/utils/editor.py +0 -207
  118. package/src/utils/gitignore_filter.py +0 -149
  119. package/src/utils/logger.py +0 -254
  120. package/src/utils/paths.py +0 -30
  121. package/src/utils/result_parsers.py +0 -108
  122. package/src/utils/safe_commands.py +0 -243
  123. package/src/utils/settings.py +0 -195
  124. package/src/utils/user_message_logger.py +0 -120
  125. package/src/utils/validation.py +0 -201
  126. package/src/utils/web_search.py +0 -173
@@ -1,226 +0,0 @@
1
- """Interactive tool confirmation panel with arrow key navigation."""
2
-
3
- import asyncio
4
- from html import escape
5
- from threading import Timer
6
- from typing import Optional, Tuple
7
- from prompt_toolkit import HTML
8
- from prompt_toolkit.application import Application
9
- from prompt_toolkit.key_binding import KeyBindings
10
- from prompt_toolkit.keys import Keys
11
- from prompt_toolkit.layout import Layout, HSplit, Window
12
- from prompt_toolkit.layout.controls import FormattedTextControl
13
-
14
-
15
-
16
-
17
- class ToolConfirmationPanel:
18
- """Interactive panel for tool execution confirmation with arrow key navigation."""
19
-
20
- # Public constants
21
- SUMMARY_DISPLAY_DELAY = 0.5 # Seconds to show summary before auto-exit
22
- CURSOR = "> "
23
- STANDARD_OPTIONS = [
24
- {"value": "accept", "text": "Accept"},
25
- {"value": "advise", "text": "Advise"},
26
- {"value": "cancel", "text": "Cancel"},
27
- ]
28
- EDIT_OPTIONS = [
29
- {"value": "accept", "text": "Accept"},
30
- {"value": "accept_all_edits", "text": "Accept All Edits"},
31
- {"value": "advise", "text": "Advise"},
32
- {"value": "cancel", "text": "Cancel"},
33
- ]
34
-
35
- def __init__(self, tool_command: str, reason: Optional[str] = None, is_edit_tool: bool = False, cycle_approve_mode=None):
36
- """Initialize the tool confirmation panel.
37
-
38
- Args:
39
- tool_command: Command/tool being executed
40
- reason: Optional reason/details about the tool execution
41
- is_edit_tool: Whether this is an edit tool (shows extra toggle option)
42
- cycle_approve_mode: Optional callback to cycle approve_mode
43
- """
44
- self.tool_command = tool_command
45
- self.reason = reason
46
- self.is_edit_tool = is_edit_tool
47
- self.cycle_approve_mode = cycle_approve_mode
48
- self.selected_index = 0
49
- self._showing_summary = False
50
- self._selected_value = None
51
- # Use appropriate options based on tool type
52
- self._options = self.EDIT_OPTIONS if is_edit_tool else self.STANDARD_OPTIONS
53
-
54
- def _append_field(self, lines: list[str], label: str, value: object, *, formatted: bool = False) -> None:
55
- """Append a field to the panel, escaping untrusted values by default."""
56
- value_text = str(value)
57
- if not formatted:
58
- value_text = escape(value_text)
59
- value_lines = value_text.splitlines() or [""]
60
- lines.append(f"<b>{escape(label)}:</b> {value_lines[0]}")
61
- for continuation in value_lines[1:]:
62
- lines.append(f" {continuation}")
63
-
64
- def _get_display_text(self) -> HTML:
65
- """Get the formatted text to display.
66
-
67
- Returns:
68
- HTML formatted text with current selection state
69
- """
70
- lines = []
71
-
72
- # Check if showing summary
73
- if self._showing_summary:
74
- # Find the option text for the selected value
75
- selected_opt = next((opt for opt in self._options if opt.get("value") == self._selected_value), None)
76
- selected_text = selected_opt.get("text", self._selected_value) if selected_opt else self._selected_value
77
-
78
- lines.append("<b>Selection Summary</b>")
79
- lines.append("")
80
- self._append_field(lines, "Tool", self.tool_command)
81
- lines.append(f'<style fg="gray"> Selected: {escape(str(selected_text))}</style>')
82
- lines.append("")
83
- else:
84
- # Tool information
85
- self._append_field(lines, "Tool", self.tool_command)
86
- if self.reason:
87
- self._append_field(lines, "Reason", self.reason)
88
- lines.append("")
89
-
90
- # Render options
91
- for idx, opt in enumerate(self._options):
92
- text = opt.get("text", "")
93
-
94
- if idx == self.selected_index:
95
- # Selected option - show cursor and highlight in bold white
96
- lines.append(f'<style fg="white" bold="true">{self.CURSOR}{text}</style>')
97
- else:
98
- # Unselected option - dark grey
99
- lines.append(f'<style fg="gray"> {text}</style>')
100
-
101
- return HTML("\n".join(lines))
102
-
103
- def _exit_with_summary(self, event, result: str) -> None:
104
- """Show summary screen and exit application after delay.
105
-
106
- Args:
107
- event: PromptToolkit event object
108
- result: Result value to return when application exits
109
- """
110
- if self._showing_summary:
111
- return
112
- self._showing_summary = True
113
- self._selected_value = result
114
- event.app.invalidate()
115
- Timer(self.SUMMARY_DISPLAY_DELAY, lambda: event.app.exit(result=result)).start()
116
-
117
- def _create_key_bindings(self) -> KeyBindings:
118
- """Create key bindings for navigation and selection.
119
-
120
- Returns:
121
- KeyBindings object with Up/Down/Enter/Esc handlers
122
- """
123
- bindings = KeyBindings()
124
-
125
- @bindings.add(Keys.Up)
126
- def move_up(event):
127
- """Move selection up."""
128
- if self.selected_index > 0:
129
- self.selected_index -= 1
130
- event.app.invalidate()
131
-
132
- @bindings.add(Keys.Down)
133
- def move_down(event):
134
- """Move selection down."""
135
- if self.selected_index < len(self._options) - 1:
136
- self.selected_index += 1
137
- event.app.invalidate()
138
-
139
- @bindings.add(Keys.Enter)
140
- def select(event):
141
- """Confirm selection."""
142
- selected_value = self._options[self.selected_index].get("value")
143
- self._selected_value = selected_value
144
-
145
- # Handle accept_all_edits option
146
- if selected_value == "accept_all_edits":
147
- # Call the callback to cycle approve_mode
148
- if self.cycle_approve_mode:
149
- self.cycle_approve_mode()
150
- # Return "accept" so the current edit proceeds
151
- self._exit_with_summary(event, "accept")
152
- else:
153
- # Show summary then auto-exit
154
- self._exit_with_summary(event, selected_value)
155
-
156
- @bindings.add(Keys.Escape)
157
- def cancel(event):
158
- """Cancel selection."""
159
- self._exit_with_summary(event, "cancel")
160
-
161
- return bindings
162
-
163
- def _create_layout(self) -> Layout:
164
- """Create the panel layout.
165
-
166
- Returns:
167
- Layout object configured for the confirmation panel
168
- """
169
- def get_content():
170
- return self._get_display_text()
171
-
172
- content_control = FormattedTextControl(get_content)
173
-
174
- root_container = HSplit([
175
- Window(content=content_control, height=None),
176
- ])
177
-
178
- return Layout(root_container)
179
-
180
- def _handle_result(self, result: str) -> Tuple[str, Optional[str]]:
181
- """Handle the result from the confirmation panel.
182
-
183
- Args:
184
- result: The action selected by the user
185
-
186
- Returns:
187
- Tuple of (action, guidance_text):
188
- - action: "accept", "advise", "accept_all_edits", or "cancel"
189
- - guidance_text: User's advice if action is "advise", None otherwise
190
- """
191
- # Handle advise input separately
192
- if result == "advise":
193
- from prompt_toolkit import PromptSession
194
- from prompt_toolkit.formatted_text import HTML
195
-
196
- guidance_session = PromptSession()
197
- guidance = guidance_session.prompt(HTML("<b>Enter your advice: </b>")).strip()
198
- if not guidance:
199
- # Empty advise treated as cancel
200
- return ("cancel", None)
201
- return ("advise", guidance)
202
-
203
- return (result, None)
204
-
205
- def run(self) -> Tuple[str, Optional[str]]:
206
- """Display the confirmation panel and wait for user input.
207
-
208
- Returns:
209
- Tuple of (action, guidance_text):
210
- - action: "accept", "advise", "accept_all_edits", or "cancel"
211
- - guidance_text: User's advice if action is "advise", None otherwise
212
- """
213
- # Create and run the application
214
- bindings = self._create_key_bindings()
215
- layout = self._create_layout()
216
-
217
- application = Application(
218
- layout=layout,
219
- key_bindings=bindings,
220
- full_screen=False,
221
- mouse_support=False,
222
- )
223
-
224
- # Use run_async with asyncio to properly await coroutines
225
- result = asyncio.run(application.run_async())
226
- return self._handle_result(result)
@@ -1 +0,0 @@
1
- """Utility modules for bone-agent."""
@@ -1,199 +0,0 @@
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)
@@ -1,207 +0,0 @@
1
- """External editor integration for bone-agent."""
2
- import os
3
- import platform
4
- import shlex
5
- import subprocess
6
- import tempfile
7
- import shutil
8
- from pathlib import Path
9
- from typing import Tuple, Optional
10
-
11
-
12
- def get_editor() -> str:
13
- """Get editor command with OS-specific defaults.
14
-
15
- Priority:
16
- 1. Windows: notepad.exe
17
- 2. Linux: nvim, then EDITOR, then nano/vi/vim
18
- 3. Other Unix: EDITOR, then nano/vi/vim
19
-
20
- Returns:
21
- str: Editor command or path
22
- """
23
- system = platform.system()
24
-
25
- if system == "Windows":
26
- return "notepad.exe"
27
-
28
- if system == "Linux" and shutil.which("nvim"):
29
- return "nvim"
30
-
31
- editor = os.environ.get("EDITOR")
32
- if editor and editor.strip():
33
- return editor.strip()
34
-
35
- for cmd in ["nano", "vi", "vim"]:
36
- if shutil.which(cmd):
37
- return cmd
38
-
39
- return "nano"
40
-
41
-
42
- def _create_temp_file() -> Tuple[Path, object]:
43
- """Create temporary file for editing.
44
-
45
- Returns:
46
- tuple: (Path object, file handle)
47
- """
48
- # Create with .md extension for better syntax highlighting
49
- temp_fd = tempfile.NamedTemporaryFile(
50
- mode='w+',
51
- suffix='.md',
52
- prefix='bone_agent_edit_',
53
- delete=False,
54
- encoding='utf-8'
55
- )
56
-
57
- temp_fd.flush()
58
-
59
- return Path(temp_fd.name), temp_fd
60
-
61
-
62
- def _strip_comment_lines(content: str) -> str:
63
- """Remove comment lines (starting with #) from content.
64
-
65
- Args:
66
- content: Raw content from editor
67
-
68
- Returns:
69
- str: Content with comment lines removed
70
- """
71
- lines = []
72
- for line in content.split('\n'):
73
- stripped = line.strip()
74
- # Keep empty lines and non-comment lines
75
- if not stripped.startswith('#'):
76
- lines.append(line)
77
-
78
- return '\n'.join(lines).strip()
79
-
80
-
81
- def _build_editor_command(editor_cmd: str, temp_path: Path) -> Tuple[bool, str | list[str]]:
82
- """Build a subprocess command for launching the configured editor."""
83
- use_shell = platform.system() == "Windows"
84
- if use_shell:
85
- return use_shell, f'{editor_cmd} "{temp_path}"'
86
-
87
- try:
88
- command_parts = shlex.split(editor_cmd)
89
- except ValueError as e:
90
- raise ValueError(f"Invalid EDITOR command: {e}") from e
91
-
92
- if not command_parts:
93
- raise FileNotFoundError(editor_cmd)
94
-
95
- return use_shell, [*command_parts, str(temp_path)]
96
-
97
-
98
- def _open_editor_with_temp_file(
99
- console,
100
- editor_cmd: str,
101
- debug_mode: bool = False,
102
- initial_content: str = "",
103
- post_process=None,
104
- ) -> Tuple[bool, Optional[str]]:
105
- """Open the configured editor for a temporary file and return the saved content."""
106
- temp_path = None
107
-
108
- try:
109
- temp_path, temp_fd = _create_temp_file()
110
- if initial_content:
111
- temp_fd.write(initial_content)
112
- temp_fd.flush()
113
- temp_fd.close()
114
-
115
- if debug_mode:
116
- console.print(f"[dim]Temp file: {temp_path}[/dim]")
117
- console.print(f"[dim]Editor: {editor_cmd}[/dim]")
118
-
119
- console.print("[#5F9EA0]Opening editor...[/#5F9EA0]")
120
- console.print("[dim]Save and close the editor when done[/dim]")
121
-
122
- use_shell, command = _build_editor_command(editor_cmd, temp_path)
123
- result = subprocess.run(
124
- command,
125
- shell=use_shell,
126
- check=False,
127
- )
128
-
129
- if result.returncode != 0 and debug_mode:
130
- console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
131
-
132
- content = temp_path.read_text(encoding="utf-8")
133
- if post_process is not None:
134
- content = post_process(content)
135
-
136
- if debug_mode:
137
- console.print(f"[dim]Read {len(content)} characters[/dim]")
138
-
139
- return (True, content)
140
-
141
- except FileNotFoundError:
142
- console.print(f"[red]Editor '{editor_cmd}' not found[/red]", markup=False)
143
- console.print("[dim]Set EDITOR as environment variable[/dim]")
144
- if debug_mode:
145
- console.print(f"[dim]Tried to run: {editor_cmd}[/dim]")
146
- return (False, None)
147
-
148
- except PermissionError as e:
149
- console.print(f"[red]Permission denied: {e}[/red]", markup=False)
150
- console.print("[dim]Check permissions on temporary directory[/dim]")
151
- return (False, None)
152
-
153
- except Exception as e:
154
- console.print(f"[red]Failed to open editor: {e}[/red]", markup=False)
155
- if debug_mode:
156
- import traceback
157
- console.print(f"[dim]{traceback.format_exc()}[/dim]")
158
- return (False, None)
159
-
160
- finally:
161
- if temp_path and temp_path.exists():
162
- try:
163
- temp_path.unlink()
164
- if debug_mode:
165
- console.print("[dim]Cleaned up temp file[/dim]")
166
- except Exception as e:
167
- console.print(f"[yellow]Warning: Failed to delete temp file: {e}[/yellow]")
168
- if debug_mode:
169
- console.print(f"[dim]Temp file may remain at: {temp_path}[/dim]")
170
-
171
-
172
- def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
173
- """Open external editor and return user input.
174
-
175
- Args:
176
- console: Rich console for output
177
- debug_mode: Whether to show debug information
178
-
179
- Returns:
180
- tuple: (success: bool, content: str or None)
181
- - (True, content) if successful
182
- - (False, None) if failed or cancelled
183
- """
184
- return _open_editor_with_temp_file(
185
- console,
186
- editor_cmd=get_editor(),
187
- debug_mode=debug_mode,
188
- post_process=_strip_comment_lines,
189
- )
190
-
191
-
192
- def open_editor_for_content(
193
- console,
194
- initial_content: str = "",
195
- debug_mode: bool = False,
196
- ) -> Tuple[bool, Optional[str]]:
197
- """Open external editor with initial content and return the saved file.
198
-
199
- Unlike open_editor_for_input, this preserves markdown comment lines so it can
200
- edit existing markdown files without dropping headings or notes.
201
- """
202
- return _open_editor_with_temp_file(
203
- console,
204
- editor_cmd=get_editor(),
205
- debug_mode=debug_mode,
206
- initial_content=initial_content or "",
207
- )