patchllm 0.2.1__py3-none-any.whl → 1.0.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 (54) hide show
  1. patchllm/__main__.py +0 -0
  2. patchllm/agent/__init__.py +0 -0
  3. patchllm/agent/actions.py +73 -0
  4. patchllm/agent/executor.py +57 -0
  5. patchllm/agent/planner.py +76 -0
  6. patchllm/agent/session.py +425 -0
  7. patchllm/cli/__init__.py +0 -0
  8. patchllm/cli/entrypoint.py +120 -0
  9. patchllm/cli/handlers.py +192 -0
  10. patchllm/cli/helpers.py +72 -0
  11. patchllm/interactive/__init__.py +0 -0
  12. patchllm/interactive/selector.py +100 -0
  13. patchllm/llm.py +39 -0
  14. patchllm/main.py +1 -283
  15. patchllm/parser.py +120 -64
  16. patchllm/patcher.py +118 -0
  17. patchllm/scopes/__init__.py +0 -0
  18. patchllm/scopes/builder.py +55 -0
  19. patchllm/scopes/constants.py +70 -0
  20. patchllm/scopes/helpers.py +147 -0
  21. patchllm/scopes/resolvers.py +82 -0
  22. patchllm/scopes/structure.py +64 -0
  23. patchllm/tui/__init__.py +0 -0
  24. patchllm/tui/completer.py +153 -0
  25. patchllm/tui/interface.py +703 -0
  26. patchllm/utils.py +19 -1
  27. patchllm/voice/__init__.py +0 -0
  28. patchllm/{listener.py → voice/listener.py} +8 -1
  29. patchllm-1.0.0.dist-info/METADATA +153 -0
  30. patchllm-1.0.0.dist-info/RECORD +51 -0
  31. patchllm-1.0.0.dist-info/entry_points.txt +2 -0
  32. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
  33. tests/__init__.py +0 -0
  34. tests/conftest.py +112 -0
  35. tests/test_actions.py +62 -0
  36. tests/test_agent.py +383 -0
  37. tests/test_completer.py +121 -0
  38. tests/test_context.py +140 -0
  39. tests/test_executor.py +60 -0
  40. tests/test_interactive.py +64 -0
  41. tests/test_parser.py +70 -0
  42. tests/test_patcher.py +71 -0
  43. tests/test_planner.py +53 -0
  44. tests/test_recipes.py +111 -0
  45. tests/test_scopes.py +47 -0
  46. tests/test_structure.py +48 -0
  47. tests/test_tui.py +397 -0
  48. tests/test_utils.py +31 -0
  49. patchllm/context.py +0 -238
  50. patchllm-0.2.1.dist-info/METADATA +0 -127
  51. patchllm-0.2.1.dist-info/RECORD +0 -12
  52. patchllm-0.2.1.dist-info/entry_points.txt +0 -2
  53. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
  54. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
patchllm/main.py CHANGED
@@ -1,286 +1,4 @@
1
- import textwrap
2
- import argparse
3
- import litellm
4
- import pprint
5
- import os
6
- from dotenv import load_dotenv
7
- from rich.console import Console
8
- from rich.panel import Panel
9
-
10
- from .context import build_context
11
- from .parser import paste_response
12
- from .utils import load_from_py_file
13
-
14
- console = Console()
15
-
16
- # --- Core Functions ---
17
-
18
- def collect_context(config_name, configs):
19
- """Builds the code context from a provided configuration dictionary."""
20
- console.print("\n--- Building Code Context... ---", style="bold")
21
- if not configs:
22
- raise FileNotFoundError("Could not find a 'configs.py' file.")
23
- selected_config = configs.get(config_name)
24
- if selected_config is None:
25
- raise KeyError(f"Context config '{config_name}' not found in provided configs file.")
26
-
27
- context_object = build_context(selected_config)
28
- if context_object:
29
- tree, context = context_object.values()
30
- console.print("--- Context Building Finished. The following files were extracted ---", style="bold")
31
- console.print(tree)
32
- return context
33
- else:
34
- console.print("--- Context Building Failed (No files found) ---", style="yellow")
35
- return None
36
-
37
- def run_update(task_instructions, model_name, history, context=None):
38
- """
39
- Assembles the final prompt, sends it to the LLM, and applies file updates.
40
- """
41
- console.print("\n--- Sending Prompt to LLM... ---", style="bold")
42
- final_prompt = task_instructions
43
- if context:
44
- final_prompt = f"{context}\n\n{task_instructions}"
45
-
46
- history.append({"role": "user", "content": final_prompt})
47
-
48
- try:
49
- with console.status("[bold cyan]Waiting for LLM response...", spinner="dots"):
50
- response = litellm.completion(model=model_name, messages=history)
51
-
52
- assistant_response_content = response.choices[0].message.content
53
- history.append({"role": "assistant", "content": assistant_response_content})
54
-
55
- if not assistant_response_content or not assistant_response_content.strip():
56
- console.print("⚠️ Response is empty. Nothing to paste.", style="yellow")
57
- return
58
-
59
- console.print("\n--- Updating files ---", style="bold")
60
- paste_response(assistant_response_content)
61
- console.print("--- File Update Process Finished ---", style="bold")
62
-
63
- except Exception as e:
64
- history.pop() # Keep history clean on error
65
- raise RuntimeError(f"An error occurred while communicating with the LLM via litellm: {e}") from e
66
-
67
- def write_context_to_file(file_path, context):
68
- """Utility function to write the context to a file."""
69
- console.print("Exporting context..", style="cyan")
70
- with open(file_path, "w", encoding="utf-8") as file:
71
- file.write(context)
72
- console.print(f'✅ Context exported to {file_path.split("/")[-1]}', style="green")
73
-
74
- def read_from_file(file_path):
75
- """Utility function to read and return the content of a file."""
76
- console.print(f"Importing from {file_path}..", style="cyan")
77
- try:
78
- with open(file_path, "r", encoding="utf-8") as file:
79
- content = file.read()
80
- console.print("✅ Finished reading file.", style="green")
81
- return content
82
- except Exception as e:
83
- raise RuntimeError(f"Failed to read from file {file_path}: {e}") from e
84
-
85
- def create_new_config(configs, configs_file_str):
86
- """Interactively creates a new configuration and saves it to the specified configs file."""
87
- console.print(f"\n--- Creating a new configuration in '{configs_file_str}' ---", style="bold")
88
-
89
- try:
90
- name = console.input("[bold]Enter a name for the new configuration: [/]").strip()
91
- if not name:
92
- console.print("❌ Configuration name cannot be empty.", style="red")
93
- return
94
-
95
- if name in configs:
96
- overwrite = console.input(f"Configuration '[bold]{name}[/]' already exists. Overwrite? (y/n): ").lower()
97
- if overwrite not in ['y', 'yes']:
98
- console.print("Operation cancelled.", style="yellow")
99
- return
100
-
101
- path = console.input("[bold]Enter the base path[/] (e.g., '.' for current directory): ").strip() or "."
102
-
103
- console.print("\nEnter comma-separated glob patterns for files to include.")
104
- include_raw = console.input('[cyan]> (e.g., "[bold]**/*.py, src/**/*.js[/]"): [/]').strip()
105
- include_patterns = [p.strip() for p in include_raw.split(',') if p.strip()]
106
-
107
- console.print("\nEnter comma-separated glob patterns for files to exclude (optional).")
108
- exclude_raw = console.input('[cyan]> (e.g., "[bold]**/tests/*, venv/*[/]"): [/]').strip()
109
- exclude_patterns = [p.strip() for p in exclude_raw.split(',') if p.strip()]
110
-
111
- console.print("\nEnter comma-separated URLs to include as context (optional).")
112
- urls_raw = console.input('[cyan]> (e.g., "[bold]https://docs.example.com, ...[/]"): [/]').strip()
113
- urls = [u.strip() for u in urls_raw.split(',') if u.strip()]
114
-
115
- new_config_data = {
116
- "path": path,
117
- "include_patterns": include_patterns,
118
- "exclude_patterns": exclude_patterns,
119
- "urls": urls,
120
- }
121
-
122
- configs[name] = new_config_data
123
-
124
- with open(configs_file_str, "w", encoding="utf-8") as f:
125
- f.write("# configs.py\n")
126
- f.write("configs = ")
127
- f.write(pprint.pformat(configs, indent=4))
128
- f.write("\n")
129
-
130
- console.print(f"\n✅ Successfully created and saved configuration '[bold]{name}[/]' in '[bold]{configs_file_str}[/]'.", style="green")
131
-
132
- except KeyboardInterrupt:
133
- console.print("\n\n⚠️ Configuration creation cancelled by user.", style="yellow")
134
- return
135
-
136
- def main():
137
- """
138
- Main entry point for the patchllm command-line tool.
139
- """
140
- load_dotenv()
141
-
142
- configs_file_path = os.getenv("PATCHLLM_CONFIGS_FILE", "./configs.py")
143
-
144
- parser = argparse.ArgumentParser(
145
- description="A CLI tool to apply code changes using an LLM.",
146
- formatter_class=argparse.RawTextHelpFormatter
147
- )
148
-
149
- parser.add_argument("-i", "--init", action="store_true", help="Create a new configuration interactively.")
150
-
151
- parser.add_argument("-c", "--config", type=str, default=None, help="Name of the config key to use from the configs file.")
152
- parser.add_argument("-t", "--task", type=str, default=None, help="The task instructions to guide the assistant.")
153
-
154
- parser.add_argument("-co", "--context-out", nargs='?', const="context.md", default=None, help="Export the generated context to a file. Defaults to 'context.md'.")
155
- parser.add_argument("-ci", "--context-in", type=str, default=None, help="Import a previously saved context from a file.")
156
-
157
- parser.add_argument("-u", "--update", type=str, default="True", help="Control whether to send the context to the LLM for updates. (True/False)")
158
- parser.add_argument("-ff", "--from-file", type=str, default=None, help="Apply updates directly from a file instead of the LLM.")
159
- parser.add_argument("-fc", "--from-clipboard", action="store_true", help="Apply updates directly from the clipboard.")
160
-
161
- parser.add_argument("--model", type=str, default="gemini/gemini-1.5-flash", help="Model name to use (e.g., 'gpt-4o', 'claude-3-sonnet').")
162
- parser.add_argument("--voice", type=str, default="False", help="Enable voice interaction for providing task instructions. (True/False)")
163
-
164
- parser.add_argument("--list-configs", action="store_true", help="List all available configurations from the configs file and exit.")
165
- parser.add_argument("--show-config", type=str, help="Display the settings for a specific configuration and exit.")
166
-
167
- args = parser.parse_args()
168
-
169
- try:
170
- configs = load_from_py_file(configs_file_path, "configs")
171
- except FileNotFoundError:
172
- configs = {}
173
- if not any([args.init, args.list_configs, args.show_config]):
174
- console.print(f"⚠️ Config file '{configs_file_path}' not found. You can create one with the --init flag.", style="yellow")
175
-
176
-
177
- if args.list_configs:
178
- console.print(f"Available configurations in '[bold]{configs_file_path}[/]':", style="bold")
179
- if not configs:
180
- console.print(f" -> No configurations found or '{configs_file_path}' is missing.")
181
- else:
182
- for config_name in configs:
183
- console.print(f" - {config_name}")
184
- return
185
-
186
- if args.show_config:
187
- config_name = args.show_config
188
- if not configs:
189
- console.print(f"⚠️ Config file '{configs_file_path}' not found or is empty.", style="yellow")
190
- return
191
-
192
- config_data = configs.get(config_name)
193
- if config_data:
194
- pretty_config = pprint.pformat(config_data, indent=2)
195
- console.print(
196
- Panel(
197
- pretty_config,
198
- title=f"[bold cyan]Configuration: '{config_name}'[/]",
199
- subtitle=f"[dim]from {configs_file_path}[/dim]",
200
- border_style="blue"
201
- )
202
- )
203
- else:
204
- console.print(f"❌ Configuration '[bold]{config_name}[/]' not found in '{configs_file_path}'.", style="red")
205
- return
206
-
207
- if args.init:
208
- create_new_config(configs, configs_file_path)
209
- return
210
-
211
- if args.from_clipboard:
212
- try:
213
- import pyperclip
214
- updates = pyperclip.paste()
215
- if updates:
216
- console.print("--- Parsing updates from clipboard ---", style="bold")
217
- paste_response(updates)
218
- else:
219
- console.print("⚠️ Clipboard is empty. Nothing to parse.", style="yellow")
220
- except ImportError:
221
- console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
222
- console.print("Please install it using: pip install pyperclip", style="cyan")
223
- except Exception as e:
224
- console.print(f"❌ An error occurred while reading from the clipboard: {e}", style="red")
225
- return
226
-
227
- if args.from_file:
228
- updates = read_from_file(args.from_file)
229
- paste_response(updates)
230
- return
231
-
232
- system_prompt = textwrap.dedent("""
233
- You are an expert pair programmer. Your purpose is to help users by modifying files based on their instructions.
234
- Follow these rules strictly:
235
- Your output should be a single file including all the updated files. For each file-block:
236
- 1. Only include code for files that need to be updated / edited.
237
- 2. For updated files, do not exclude any code even if it is unchanged code; assume the file code will be copy-pasted full in the file.
238
- 3. Do not include verbose inline comments explaining what every small change does. Try to keep comments concise but informative, if any.
239
- 4. Only update the relevant parts of each file relative to the provided task; do not make irrelevant edits even if you notice areas of improvements elsewhere.
240
- 5. Do not use diffs.
241
- 6. Make sure each file-block is returned in the following exact format. No additional text, comments, or explanations should be outside these blocks.
242
- Expected format for a modified or new file:
243
- <file_path:/absolute/path/to/your/file.py>
244
- ```python
245
- # The full, complete content of /absolute/path/to/your/file.py goes here.
246
- def example_function():
247
- return "Hello, World!"
248
- ```
249
- """)
250
- history = [{"role": "system", "content": system_prompt}]
251
-
252
- context = None
253
- if args.voice not in ["False", "false"]:
254
- from .listener import listen, speak
255
- speak("Say your task instruction.")
256
- task = listen()
257
- if not task:
258
- speak("No instruction heard. Exiting.")
259
- return
260
- speak(f"You said: {task}. Should I proceed?")
261
- confirm = listen()
262
- if confirm and "yes" in confirm.lower():
263
- context = collect_context(args.config, configs)
264
- run_update(task, args.model, history, context)
265
- speak("Changes applied.")
266
- else:
267
- speak("Cancelled.")
268
- return
269
-
270
- if args.context_in:
271
- context = read_from_file(args.context_in)
272
- else:
273
- if not args.config:
274
- parser.error("A --config name is required unless using other flags like --context-in or other utility flags.")
275
- context = collect_context(args.config, configs)
276
- if context and args.context_out:
277
- write_context_to_file(args.context_out, context)
278
-
279
- if args.update not in ["False", "false"]:
280
- if not args.task:
281
- parser.error("The --task argument is required to generate updates.")
282
- if context:
283
- run_update(args.task, args.model, history, context)
1
+ from .cli.entrypoint import main
284
2
 
285
3
  if __name__ == "__main__":
286
4
  main()
patchllm/parser.py CHANGED
@@ -1,3 +1,4 @@
1
+ import difflib
1
2
  import re
2
3
  from pathlib import Path
3
4
  from rich.console import Console
@@ -6,80 +7,135 @@ from rich.text import Text
6
7
 
7
8
  console = Console()
8
9
 
9
- def paste_response(response_content):
10
+ def _parse_file_blocks(response: str) -> list[tuple[Path, str]]:
11
+ """Parses the LLM response to extract file paths and their content."""
12
+ pattern = r"<file_path:(.*?)>\n```(?:\w+\n)?(.*?)\n```"
13
+ matches = re.findall(pattern, response, re.DOTALL)
14
+
15
+ parsed_blocks = []
16
+ for path_str, content in matches:
17
+ path_obj = Path(path_str.strip()).resolve()
18
+ # --- CORRECTION: Strip leading/trailing whitespace from content ---
19
+ parsed_blocks.append((path_obj, content.strip()))
20
+
21
+ return parsed_blocks
22
+
23
+ def parse_change_summary(response: str) -> str | None:
24
+ """Parses the LLM response to extract the change summary."""
25
+ pattern = r"<change_summary>(.*?)</change_summary>"
26
+ match = re.search(pattern, response, re.DOTALL)
27
+ if match:
28
+ return match.group(1).strip()
29
+ return None
30
+
31
+ def paste_response(response: str):
32
+ """Applies all file updates from the LLM's response to the local filesystem."""
33
+ parsed_blocks = _parse_file_blocks(response)
34
+ if not parsed_blocks:
35
+ console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
36
+ return
37
+
38
+ for file_path, new_content in parsed_blocks:
39
+ try:
40
+ file_path.parent.mkdir(parents=True, exist_ok=True)
41
+ file_path.write_text(new_content, encoding="utf-8")
42
+ console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
43
+ except Exception as e:
44
+ console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
45
+
46
+ def paste_response_selectively(response: str, files_to_apply: list[str]):
10
47
  """
11
- Parses a response containing code blocks and writes them to files,
12
- handling both absolute and relative paths safely.
48
+ Applies file updates from the LLM's response only to the user-selected files.
13
49
 
14
50
  Args:
15
- response_content (str): The string response from the LLM.
51
+ response (str): The full response from the LLM.
52
+ files_to_apply (list[str]): A list of absolute file path strings to modify.
16
53
  """
17
- pattern = re.compile(
18
- r"<file_path:([^>]+?)>\s*```(?:.*?)\n(.*?)\n```",
19
- re.DOTALL | re.MULTILINE
20
- )
54
+ parsed_blocks = _parse_file_blocks(response)
55
+ if not parsed_blocks:
56
+ console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
57
+ return
21
58
 
22
- matches = pattern.finditer(response_content)
23
-
24
- files_written = []
25
- files_skipped = []
26
- files_failed = []
27
- found_matches = False
28
-
29
- for match in matches:
30
- found_matches = True
31
- file_path_str = match.group(1).strip()
32
- code_content = match.group(2)
33
-
34
- if not file_path_str:
35
- console.print("⚠️ Found a code block with an empty file path. Skipping.", style="yellow")
36
- continue
37
-
38
- console.print(f"Found path in response: '[cyan]{file_path_str}[/]'")
39
- raw_path = Path(file_path_str)
40
-
41
- if raw_path.is_absolute():
42
- target_path = raw_path
59
+ files_to_apply_set = set(files_to_apply)
60
+ applied_count = 0
61
+
62
+ for file_path, new_content in parsed_blocks:
63
+ if file_path.as_posix() in files_to_apply_set:
64
+ try:
65
+ file_path.parent.mkdir(parents=True, exist_ok=True)
66
+ file_path.write_text(new_content, encoding="utf-8")
67
+ console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
68
+ applied_count += 1
69
+ except Exception as e:
70
+ console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
71
+
72
+ if applied_count == 0:
73
+ console.print("No changes were applied.", style="yellow")
74
+
75
+
76
+ def summarize_changes(response: str) -> dict:
77
+ """Summarizes which files will be created and which will be modified."""
78
+ parsed_blocks = _parse_file_blocks(response)
79
+ summary = {"created": [], "modified": []}
80
+ for file_path, _ in parsed_blocks:
81
+ if file_path.exists():
82
+ summary["modified"].append(file_path.as_posix())
43
83
  else:
44
- target_path = Path.cwd() / raw_path
84
+ summary["created"].append(file_path.as_posix())
85
+ return summary
45
86
 
46
- target_path = target_path.resolve()
87
+ def get_diff_for_file(file_path_str: str, response: str) -> str:
88
+ """Generates a colorized, unified diff for a single file from the response."""
89
+ parsed_blocks = _parse_file_blocks(response)
90
+ file_path = Path(file_path_str).resolve()
91
+
92
+ new_content = None
93
+ for p, content in parsed_blocks:
94
+ if p == file_path:
95
+ new_content = content
96
+ break
97
+
98
+ if new_content is None:
99
+ return f"Could not find content for {file_path_str} in the response."
47
100
 
101
+ original_content = ""
102
+ if file_path.exists():
48
103
  try:
49
- target_path.parent.mkdir(parents=True, exist_ok=True)
50
-
51
- if target_path.exists():
52
- with open(target_path, 'r', encoding='utf-8') as existing_file:
53
- if existing_file.read() == code_content:
54
- console.print(f" -> No changes for '[cyan]{target_path}[/]', skipping.", style="dim")
55
- files_skipped.append(target_path)
56
- continue
104
+ original_content = file_path.read_text(encoding="utf-8")
105
+ except Exception:
106
+ return f"Could not read original content of {file_path_str}."
57
107
 
58
- with open(target_path, 'w', encoding='utf-8') as outfile:
59
- outfile.write(code_content)
108
+ diff = difflib.unified_diff(
109
+ original_content.splitlines(keepends=True),
110
+ new_content.splitlines(keepends=True),
111
+ fromfile=f"a/{file_path.name}",
112
+ tofile=f"b/{file_path.name}",
113
+ )
60
114
 
61
- console.print(f" -> Wrote {len(code_content)} bytes to '[cyan]{target_path}[/]'", style="green")
62
- files_written.append(target_path)
115
+ diff_text = Text()
116
+ for line in diff:
117
+ if line.startswith('+'):
118
+ diff_text.append(line, style="green")
119
+ elif line.startswith('-'):
120
+ diff_text.append(line, style="red")
121
+ elif line.startswith('^'):
122
+ diff_text.append(line, style="blue")
123
+ else:
124
+ diff_text.append(line)
125
+
126
+ return diff_text
63
127
 
64
- except OSError as e:
65
- console.print(f" -> Error writing file '[cyan]{target_path}[/]': {e}", style="red")
66
- files_failed.append(target_path)
67
- except Exception as e:
68
- console.print(f" -> ❌ An unexpected error occurred for file '[cyan]{target_path}[/]': {e}", style="red")
69
- files_failed.append(target_path)
70
-
71
- summary_text = Text()
72
- if not found_matches:
73
- summary_text.append("No file paths and code blocks matching the expected format were found in the response.", style="yellow")
74
- else:
75
- if files_written:
76
- summary_text.append(f"Successfully wrote {len(files_written)} file(s).\n", style="green")
77
- if files_skipped:
78
- summary_text.append(f"Skipped {len(files_skipped)} file(s) (no changes).\n", style="cyan")
79
- if files_failed:
80
- summary_text.append(f"Failed to write {len(files_failed)} file(s).\n", style="red")
128
+ def display_diff(response: str):
129
+ """Displays the diff for all changes proposed in the response."""
130
+ summary = summarize_changes(response)
131
+ all_files = summary.get("modified", []) + summary.get("created", [])
132
+
133
+ console.print("\n--- Proposed Changes (Diff) ---", style="bold yellow")
134
+
135
+ if not all_files:
136
+ console.print("No file changes detected in the response.")
137
+ return
81
138
 
82
- if not any([files_written, files_skipped, files_failed]):
83
- summary_text.append("Found matching blocks, but no files were processed.", style="yellow")
84
-
85
- console.print(Panel(summary_text, title="[bold]Summary[/bold]", border_style="blue"))
139
+ for file_path in all_files:
140
+ diff_text = get_diff_for_file(file_path, response)
141
+ console.print(Panel(diff_text, title=f"[bold cyan]Diff: {Path(file_path).name}[/bold cyan]", border_style="yellow"))
patchllm/patcher.py ADDED
@@ -0,0 +1,118 @@
1
+ import subprocess
2
+ import re
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from InquirerPy import prompt
6
+ from InquirerPy.exceptions import InvalidArgument
7
+ import textwrap
8
+
9
+ from .parser import get_diff_for_file, _parse_file_blocks, paste_response
10
+ from .scopes.helpers import find_files
11
+
12
+ console = Console()
13
+
14
+ def _is_diff_format(text: str) -> bool:
15
+ """Checks if the text appears to be in the unified diff format."""
16
+ for line in text.splitlines():
17
+ if line.strip(): # Find the first non-empty line
18
+ return line.startswith("--- a/")
19
+ return False
20
+
21
+ def _apply_diff(diff_text: str, base_path: Path):
22
+ """Applies a patch using the system's `patch` command."""
23
+ try:
24
+ # --- MODIFICATION: Added --ignore-whitespace for robustness ---
25
+ proc = subprocess.run(
26
+ ["patch", "-p1", "--ignore-whitespace"],
27
+ input=diff_text,
28
+ text=True,
29
+ capture_output=True,
30
+ check=True,
31
+ cwd=base_path
32
+ )
33
+ console.print("✅ Patch applied successfully.", style="green")
34
+ if proc.stdout:
35
+ console.print(proc.stdout)
36
+ except FileNotFoundError:
37
+ console.print("❌ Failed to apply patch.", style="red")
38
+ console.print("Error: The 'patch' command-line utility was not found.", style="yellow")
39
+ console.print("Please install it to apply diff-formatted patches:", style="yellow")
40
+ console.print(" - On Debian/Ubuntu: sudo apt-get install patch", style="cyan")
41
+ console.print(" - On macOS (Homebrew): brew install patch", style="cyan")
42
+ console.print(" - On Termux: pkg install patch", style="cyan")
43
+ except subprocess.CalledProcessError as e:
44
+ console.print("❌ Failed to apply patch.", style="red")
45
+ console.print("The `patch` command failed. This may be due to a mismatch between the diff and the file content.", style="yellow")
46
+ if e.stderr:
47
+ console.print("Error details:", style="yellow")
48
+ console.print(e.stderr)
49
+ except Exception as e:
50
+ console.print(f"❌ An unexpected error occurred while applying the patch: {e}", style="red")
51
+
52
+
53
+ def _interactive_file_selection(base_path: Path) -> Path | None:
54
+ """Prompts the user to select a file from the project."""
55
+ try:
56
+ all_files = find_files(base_path, ["**/*"])
57
+ choices = [p.relative_to(base_path).as_posix() for p in all_files]
58
+ if not choices:
59
+ console.print("No files found in the project.", style="yellow")
60
+ return None
61
+
62
+ question = {
63
+ "type": "fuzzy", "name": "file", "message": "Which file should this code be applied to?",
64
+ "choices": choices, "border": True
65
+ }
66
+ result = prompt([question], vi_mode=True)
67
+ return base_path / result.get("file") if result else None
68
+ except (InvalidArgument, IndexError, KeyError):
69
+ return None
70
+
71
+ def apply_external_patch(content: str, base_path: Path):
72
+ """
73
+ Intelligently applies code updates from an external source.
74
+ Handles standard diffs, patchllm's format, and ambiguous code blocks.
75
+ """
76
+ if _is_diff_format(content):
77
+ console.print("Detected `diff` format. Attempting to apply with `patch`...", style="cyan")
78
+ _apply_diff(content, base_path)
79
+ return
80
+
81
+ clean_content = textwrap.dedent(content).strip()
82
+
83
+ parsed_blocks = _parse_file_blocks(clean_content)
84
+ if parsed_blocks:
85
+ console.print("Detected `patchllm` format. Applying changes...", style="cyan")
86
+ paste_response(clean_content)
87
+ return
88
+
89
+ console.print("Could not parse a standard format. Entering interactive mode...", style="yellow")
90
+
91
+ code_block_match = re.search(r"```(?:\w+)?\s*\n(.*?)\n\s*```", clean_content, re.DOTALL)
92
+ if not code_block_match:
93
+ console.print("❌ No code blocks found in the input.", style="red")
94
+ return
95
+
96
+ code_to_apply = code_block_match.group(1).strip()
97
+ target_file = _interactive_file_selection(base_path)
98
+
99
+ if not target_file:
100
+ console.print("Cancelled.", style="yellow")
101
+ return
102
+
103
+ response_for_diff = f"<file_path:{target_file.as_posix()}>\n```\n{code_to_apply}\n```"
104
+ diff_text = get_diff_for_file(str(target_file), response_for_diff)
105
+ console.print("\n--- Proposed Changes ---", style="bold yellow")
106
+ console.print(diff_text)
107
+
108
+ try:
109
+ confirm_q = {"type": "confirm", "name": "confirm", "message": f"Apply these changes to '{target_file.name}'?", "default": True}
110
+ result = prompt([confirm_q])
111
+ if result and result.get("confirm"):
112
+ target_file.parent.mkdir(parents=True, exist_ok=True)
113
+ target_file.write_text(code_to_apply, encoding="utf-8")
114
+ console.print(f"✅ Updated [bold cyan]{target_file.name}[/bold cyan]", style="green")
115
+ else:
116
+ console.print("Cancelled.", style="yellow")
117
+ except (InvalidArgument, IndexError, KeyError):
118
+ console.print("Cancelled.", style="yellow")
File without changes
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+ from rich.console import Console
3
+
4
+ from . import resolvers, structure, helpers
5
+ from .constants import DEFAULT_EXCLUDE_EXTENSIONS
6
+
7
+ console = Console()
8
+
9
+ def build_context_from_files(file_paths: list[Path], base_path: Path) -> dict | None:
10
+ """Builds the context string directly from a provided list of file paths."""
11
+ if not file_paths:
12
+ console.print("\n⚠️ No files were provided to build the context.", style="yellow")
13
+ return None
14
+ return helpers._format_context(file_paths, [], base_path)
15
+
16
+ def build_context(scope_name: str, scopes: dict, base_path: Path) -> dict | None:
17
+ """Builds the context string from files, handling static and dynamic scopes."""
18
+ if scope_name == "@structure":
19
+ return structure._build_structure_context(base_path)
20
+
21
+ relevant_files = []
22
+ urls = []
23
+
24
+ if scope_name.startswith('@'):
25
+ console.print(f"Resolving dynamic scope: [bold cyan]{scope_name}[/bold cyan]")
26
+ relevant_files = resolvers.resolve_dynamic_scope(scope_name, base_path)
27
+ else:
28
+ scope = scopes.get(scope_name)
29
+ if not scope:
30
+ console.print(f"❌ Static scope '{scope_name}' not found.", style="red")
31
+ return None
32
+
33
+ scope_path = Path(scope.get("path", ".")).resolve()
34
+ include = scope.get("include_patterns", [])
35
+ exclude = scope.get("exclude_patterns", [])
36
+ search = scope.get("search_words", [])
37
+ urls = scope.get("urls", [])
38
+
39
+ relevant_files = helpers.find_files(scope_path, include, exclude)
40
+ if search:
41
+ relevant_files = helpers.filter_files_by_keyword(relevant_files, search)
42
+
43
+ if not relevant_files and not urls:
44
+ console.print("\n⚠️ No files or URLs matched the specified criteria.", style="yellow")
45
+ return None
46
+
47
+ exclude_exts = scopes.get(scope_name, {}).get("exclude_extensions", DEFAULT_EXCLUDE_EXTENSIONS)
48
+ norm_ext = {ext.lower() for ext in exclude_exts}
49
+ relevant_files = [p for p in relevant_files if p.suffix.lower() not in norm_ext]
50
+
51
+ if not relevant_files and not urls:
52
+ console.print("\n⚠️ No files left after extension filtering.", style="yellow")
53
+ return None
54
+
55
+ return helpers._format_context(relevant_files, urls, base_path)