patch-chat 0.1.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 (146) hide show
  1. patch/__init__.py +20 -0
  2. patch/__main__.py +4 -0
  3. patch/_version.py +24 -0
  4. patch/analytics.py +260 -0
  5. patch/args.py +955 -0
  6. patch/args_formatter.py +228 -0
  7. patch/coders/__init__.py +32 -0
  8. patch/coders/architect_coder.py +47 -0
  9. patch/coders/architect_prompts.py +40 -0
  10. patch/coders/ask_coder.py +9 -0
  11. patch/coders/ask_prompts.py +41 -0
  12. patch/coders/base_coder.py +2548 -0
  13. patch/coders/base_prompts.py +60 -0
  14. patch/coders/chat_chunks.py +64 -0
  15. patch/coders/context_coder.py +53 -0
  16. patch/coders/context_prompts.py +75 -0
  17. patch/coders/edit_apply.py +6 -0
  18. patch/coders/editblock_coder.py +620 -0
  19. patch/coders/editblock_fenced_coder.py +10 -0
  20. patch/coders/editblock_fenced_prompts.py +143 -0
  21. patch/coders/editblock_func_coder.py +141 -0
  22. patch/coders/editblock_func_prompts.py +27 -0
  23. patch/coders/editblock_prompts.py +172 -0
  24. patch/coders/editor_diff_fenced_coder.py +9 -0
  25. patch/coders/editor_diff_fenced_prompts.py +11 -0
  26. patch/coders/editor_editblock_coder.py +8 -0
  27. patch/coders/editor_editblock_prompts.py +18 -0
  28. patch/coders/editor_whole_coder.py +8 -0
  29. patch/coders/editor_whole_prompts.py +10 -0
  30. patch/coders/patch_coder.py +731 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +419 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1668 -0
  45. patch/copypaste.py +70 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/dump.py +29 -0
  49. patch/editor.py +147 -0
  50. patch/exceptions.py +113 -0
  51. patch/format_settings.py +47 -0
  52. patch/gui.py +545 -0
  53. patch/history.py +143 -0
  54. patch/io.py +1209 -0
  55. patch/linter.py +304 -0
  56. patch/llm.py +47 -0
  57. patch/main.py +1466 -0
  58. patch/mdstream.py +243 -0
  59. patch/models.py +1342 -0
  60. patch/onboarding.py +428 -0
  61. patch/openrouter.py +128 -0
  62. patch/prompts.py +61 -0
  63. patch/queries/tree-sitter-language-pack/README.md +7 -0
  64. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  65. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  66. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  67. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  68. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  69. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  70. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  71. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  72. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  73. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  74. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  75. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  76. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  77. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  78. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  79. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  80. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  81. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  82. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  83. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  84. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  85. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  86. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  87. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  88. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  89. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  90. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  91. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  92. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  93. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  94. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  95. patch/queries/tree-sitter-languages/README.md +24 -0
  96. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  97. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  98. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  99. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  100. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  101. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  102. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  103. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  104. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  105. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  106. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  107. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  108. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  109. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  110. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  111. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  112. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  113. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  114. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  115. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  116. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  117. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  118. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  119. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  120. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  121. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  122. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  123. patch/reasoning_tags.py +82 -0
  124. patch/repo.py +612 -0
  125. patch/repomap.py +859 -0
  126. patch/report.py +200 -0
  127. patch/resources/__init__.py +3 -0
  128. patch/resources/model-metadata.json +715 -0
  129. patch/resources/model-settings.yml +3128 -0
  130. patch/run_cmd.py +146 -0
  131. patch/scrape.py +316 -0
  132. patch/sendchat.py +61 -0
  133. patch/special.py +203 -0
  134. patch/urls.py +17 -0
  135. patch/utils.py +365 -0
  136. patch/versioncheck.py +113 -0
  137. patch/voice.py +200 -0
  138. patch/waiting.py +222 -0
  139. patch/watch.py +318 -0
  140. patch/watch_prompts.py +12 -0
  141. patch_chat-0.1.0.dist-info/METADATA +375 -0
  142. patch_chat-0.1.0.dist-info/RECORD +146 -0
  143. patch_chat-0.1.0.dist-info/WHEEL +5 -0
  144. patch_chat-0.1.0.dist-info/entry_points.txt +2 -0
  145. patch_chat-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  146. patch_chat-0.1.0.dist-info/top_level.txt +1 -0
patch/main.py ADDED
@@ -0,0 +1,1466 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+ import threading
6
+ import traceback
7
+ import webbrowser
8
+ from dataclasses import fields
9
+ from pathlib import Path
10
+
11
+ try:
12
+ import git
13
+ except ImportError:
14
+ git = None
15
+
16
+ import importlib_resources
17
+ import shtab
18
+ import yaml
19
+ from dotenv import dotenv_values, load_dotenv
20
+ from prompt_toolkit.enums import EditingMode
21
+
22
+ from patch import __version__, models, urls, utils
23
+ from patch.analytics import Analytics
24
+ from patch.args import get_parser
25
+ from patch.coders import Coder
26
+ from patch.coders.base_coder import UnknownEditFormat
27
+ from patch.commands import Commands, SwitchCoder
28
+ from patch.copypaste import ClipboardWatcher
29
+ from patch.deprecated import handle_deprecated_model_args
30
+ from patch.format_settings import format_settings, scrub_sensitive_info
31
+ from patch.history import ChatSummary
32
+ from patch.io import InputOutput
33
+ from patch.llm import litellm # noqa: F401; properly init litellm on launch
34
+ from patch.models import ModelSettings
35
+ from patch.onboarding import offer_openrouter_oauth, select_default_model
36
+ from patch.repo import ANY_GIT_ERROR, GitRepo
37
+ from patch.report import report_uncaught_exceptions
38
+ from patch.versioncheck import check_version, install_from_main_branch, install_upgrade
39
+ from patch.watch import FileWatcher
40
+
41
+ from .dump import dump # noqa: F401
42
+
43
+
44
+ def check_config_files_for_yes(config_files):
45
+ found = False
46
+ for config_file in config_files:
47
+ if Path(config_file).exists():
48
+ try:
49
+ with open(config_file, "r") as f:
50
+ for line in f:
51
+ if line.strip().startswith("yes:"):
52
+ print("Configuration error detected.")
53
+ print(f"The file {config_file} contains a line starting with 'yes:'")
54
+ print("Please replace 'yes:' with 'yes-always:' in this file.")
55
+ found = True
56
+ except Exception:
57
+ pass
58
+ return found
59
+
60
+
61
+ def get_git_root():
62
+ """Try and guess the git repo, since the conf.yml can be at the repo root"""
63
+ try:
64
+ repo = git.Repo(search_parent_directories=True)
65
+ return repo.working_tree_dir
66
+ except (git.InvalidGitRepositoryError, FileNotFoundError):
67
+ return None
68
+
69
+
70
+ def guessed_wrong_repo(io, git_root, fnames, git_dname):
71
+ """After we parse the args, we can determine the real repo. Did we guess wrong?"""
72
+
73
+ try:
74
+ check_repo = Path(GitRepo(io, fnames, git_dname).root).resolve()
75
+ except (OSError,) + ANY_GIT_ERROR:
76
+ return
77
+
78
+ # we had no guess, rely on the "true" repo result
79
+ if not git_root:
80
+ return str(check_repo)
81
+
82
+ git_root = Path(git_root).resolve()
83
+ if check_repo == git_root:
84
+ return
85
+
86
+ return str(check_repo)
87
+
88
+
89
+ def make_new_repo(git_root, io):
90
+ try:
91
+ repo = git.Repo.init(git_root)
92
+ check_gitignore(git_root, io, False)
93
+ except ANY_GIT_ERROR as err: # issue #1233
94
+ io.tool_error(f"Unable to create git repo in {git_root}")
95
+ io.tool_output(str(err))
96
+ return
97
+
98
+ io.tool_output(f"Git repository created in {git_root}")
99
+ return repo
100
+
101
+
102
+ def setup_git(git_root, io):
103
+ if git is None:
104
+ return
105
+
106
+ try:
107
+ cwd = Path.cwd()
108
+ except OSError:
109
+ cwd = None
110
+
111
+ repo = None
112
+
113
+ if git_root:
114
+ try:
115
+ repo = git.Repo(git_root)
116
+ except ANY_GIT_ERROR:
117
+ pass
118
+ elif cwd == Path.home():
119
+ io.tool_warning(
120
+ "You should probably run Patch in your project's directory, not your home dir."
121
+ )
122
+ return
123
+ elif cwd and io.confirm_ask(
124
+ "No git repo found, create one to track Patch's changes (recommended)?"
125
+ ):
126
+ git_root = str(cwd.resolve())
127
+ repo = make_new_repo(git_root, io)
128
+
129
+ if not repo:
130
+ return
131
+
132
+ try:
133
+ user_name = repo.git.config("--get", "user.name") or None
134
+ except git.exc.GitCommandError:
135
+ user_name = None
136
+
137
+ try:
138
+ user_email = repo.git.config("--get", "user.email") or None
139
+ except git.exc.GitCommandError:
140
+ user_email = None
141
+
142
+ if user_name and user_email:
143
+ return repo.working_tree_dir
144
+
145
+ with repo.config_writer() as git_config:
146
+ if not user_name:
147
+ git_config.set_value("user", "name", "Your Name")
148
+ io.tool_warning('Update git name with: git config user.name "Your Name"')
149
+ if not user_email:
150
+ git_config.set_value("user", "email", "you@example.com")
151
+ io.tool_warning('Update git email with: git config user.email "you@example.com"')
152
+
153
+ return repo.working_tree_dir
154
+
155
+
156
+ def check_gitignore(git_root, io, ask=True):
157
+ if not git_root:
158
+ return
159
+
160
+ try:
161
+ repo = git.Repo(git_root)
162
+ patterns_to_add = []
163
+
164
+ if not repo.ignored(".patch"):
165
+ patterns_to_add.append(".patch*")
166
+
167
+ env_path = Path(git_root) / ".env"
168
+ if env_path.exists() and not repo.ignored(".env"):
169
+ patterns_to_add.append(".env")
170
+
171
+ if not patterns_to_add:
172
+ return
173
+
174
+ gitignore_file = Path(git_root) / ".gitignore"
175
+ if gitignore_file.exists():
176
+ try:
177
+ content = io.read_text(gitignore_file)
178
+ if content is None:
179
+ return
180
+ if not content.endswith("\n"):
181
+ content += "\n"
182
+ except OSError as e:
183
+ io.tool_error(f"Error when trying to read {gitignore_file}: {e}")
184
+ return
185
+ else:
186
+ content = ""
187
+ except ANY_GIT_ERROR:
188
+ return
189
+
190
+ if ask:
191
+ io.tool_output("You can skip this check with --no-gitignore")
192
+ if not io.confirm_ask(f"Add {', '.join(patterns_to_add)} to .gitignore (recommended)?"):
193
+ return
194
+
195
+ content += "\n".join(patterns_to_add) + "\n"
196
+
197
+ try:
198
+ io.write_text(gitignore_file, content)
199
+ io.tool_output(f"Added {', '.join(patterns_to_add)} to .gitignore")
200
+ except OSError as e:
201
+ io.tool_error(f"Error when trying to write to {gitignore_file}: {e}")
202
+ io.tool_output(
203
+ "Try running with appropriate permissions or manually add these patterns to .gitignore:"
204
+ )
205
+ for pattern in patterns_to_add:
206
+ io.tool_output(f" {pattern}")
207
+
208
+
209
+ def check_streamlit_install(io):
210
+ return utils.check_pip_install_extra(
211
+ io,
212
+ "streamlit",
213
+ "You need to install the Patch browser feature",
214
+ ["patch-chat[browser]"],
215
+ )
216
+
217
+
218
+ def write_streamlit_credentials():
219
+ from streamlit.file_util import get_streamlit_file_path
220
+
221
+ # See https://github.com/PierrunoYT/patch/issues/772
222
+
223
+ credential_path = Path(get_streamlit_file_path()) / "credentials.toml"
224
+ if not os.path.exists(credential_path):
225
+ empty_creds = '[general]\nemail = ""\n'
226
+
227
+ os.makedirs(os.path.dirname(credential_path), exist_ok=True)
228
+ with open(credential_path, "w") as f:
229
+ f.write(empty_creds)
230
+ else:
231
+ print("Streamlit credentials already exist.")
232
+
233
+
234
+ def launch_gui(args):
235
+ from streamlit.web import cli
236
+
237
+ from patch import gui
238
+
239
+ print()
240
+ print("CONTROL-C to exit...")
241
+
242
+ # Necessary so streamlit does not prompt the user for an email address.
243
+ write_streamlit_credentials()
244
+
245
+ target = gui.__file__
246
+
247
+ st_args = ["run", target]
248
+
249
+ st_args += [
250
+ "--browser.gatherUsageStats=false",
251
+ "--runner.magicEnabled=false",
252
+ "--server.runOnSave=false",
253
+ ]
254
+
255
+ # https://github.com/PierrunoYT/patch/issues/2193
256
+ is_dev = "-dev" in str(__version__)
257
+
258
+ if is_dev:
259
+ print("Watching for file changes.")
260
+ else:
261
+ st_args += [
262
+ "--global.developmentMode=false",
263
+ "--server.fileWatcherType=none",
264
+ "--client.toolbarMode=viewer", # minimal?
265
+ ]
266
+
267
+ st_args += ["--"] + args
268
+
269
+ cli.main(st_args)
270
+
271
+ # from click.testing import CliRunner
272
+ # runner = CliRunner()
273
+ # from streamlit.web import bootstrap
274
+ # bootstrap.load_config_options(flag_options={})
275
+ # cli.main_run(target, args)
276
+ # sys.argv = ['streamlit', 'run', '--'] + args
277
+
278
+
279
+ def parse_lint_cmds(lint_cmds, io):
280
+ err = False
281
+ res = dict()
282
+ for lint_cmd in lint_cmds:
283
+ if re.match(r"^[a-z]+:.*", lint_cmd):
284
+ pieces = lint_cmd.split(":")
285
+ lang = pieces[0]
286
+ cmd = lint_cmd[len(lang) + 1 :]
287
+ lang = lang.strip()
288
+ else:
289
+ lang = None
290
+ cmd = lint_cmd
291
+
292
+ cmd = cmd.strip()
293
+
294
+ if cmd:
295
+ res[lang] = cmd
296
+ else:
297
+ io.tool_error(f'Unable to parse --lint-cmd "{lint_cmd}"')
298
+ io.tool_output('The arg should be "language: cmd --args ..."')
299
+ io.tool_output('For example: --lint-cmd "python: flake8 --select=E9"')
300
+ err = True
301
+ if err:
302
+ return
303
+ return res
304
+
305
+
306
+ # The repository under edit is untrusted input: the whole point of the tool is
307
+ # to be pointed at code you have not reviewed yet. These settings either run a
308
+ # shell command, load arbitrary files, or weaken transport security, so they are
309
+ # refused when they arrive from a config/env file inside that repository.
310
+ # Pass --trust-repo-config (command line only) to opt back in.
311
+ UNSAFE_CONFIG_KEYS = {
312
+ "test",
313
+ "test-cmd",
314
+ "lint",
315
+ "lint-cmd",
316
+ "auto-test",
317
+ "auto-lint",
318
+ "load",
319
+ "notifications-command",
320
+ "editor",
321
+ "verify-ssl",
322
+ "git-commit-verify",
323
+ # Listed here so a repository cannot grant itself trust from its own
324
+ # .patch.conf.yml or .env.
325
+ "trust-repo-config",
326
+ }
327
+
328
+ # Variables that redirect where prompts and source code are sent, or that
329
+ # substitute credentials.
330
+ UNSAFE_ENV_VARS = {
331
+ "ALL_PROXY",
332
+ "CURL_CA_BUNDLE",
333
+ "HTTPS_PROXY",
334
+ "HTTP_PROXY",
335
+ "REQUESTS_CA_BUNDLE",
336
+ "SSL_CERT_FILE",
337
+ }
338
+
339
+ UNSAFE_ENV_SUFFIXES = ("_API_BASE", "_BASE_URL", "_API_URL", "_ENDPOINT")
340
+
341
+
342
+ def config_key_to_dest(key):
343
+ return str(key).strip().lower().replace("-", "_")
344
+
345
+
346
+ def env_flag_is_set(name):
347
+ value = os.environ.get(name)
348
+ if value is None:
349
+ return False
350
+ return value.strip().lower() in ("1", "true", "yes", "on")
351
+
352
+
353
+ def is_unsafe_env_var(name):
354
+ upper = str(name).strip().upper()
355
+
356
+ if upper in UNSAFE_ENV_VARS:
357
+ return True
358
+ if upper.endswith(UNSAFE_ENV_SUFFIXES):
359
+ return True
360
+
361
+ # PATCH_TEST_CMD, PATCH_LINT_CMD, PATCH_LOAD, ... reach argparse through
362
+ # auto_env_var_prefix and are exactly as dangerous as the yaml keys.
363
+ if upper.startswith("PATCH_"):
364
+ dest = upper[len("PATCH_") :].lower()
365
+ if dest in {config_key_to_dest(key) for key in UNSAFE_CONFIG_KEYS}:
366
+ return True
367
+
368
+ return False
369
+
370
+
371
+ def is_trusted_config_path(fname, default_file, command_line_file=None):
372
+ """Is this config/env file the user's own, rather than the repository's?
373
+
374
+ Only the home-directory copy and a path the user named explicitly on the
375
+ command line are trusted. Anything auto-discovered in the cwd or the git
376
+ root ships with the code being edited.
377
+ """
378
+ try:
379
+ resolved = Path(fname).resolve()
380
+ except (OSError, RuntimeError):
381
+ return False
382
+
383
+ candidates = [Path.home() / default_file, Path.home() / ".patch" / "oauth-keys.env"]
384
+ if command_line_file:
385
+ candidates.append(Path(command_line_file))
386
+
387
+ for candidate in candidates:
388
+ try:
389
+ if resolved == Path(candidate).resolve():
390
+ return True
391
+ except (OSError, RuntimeError):
392
+ pass
393
+
394
+ return False
395
+
396
+
397
+ def unsafe_keys_in_config(fname):
398
+ try:
399
+ with open(fname, "r", encoding="utf-8") as f:
400
+ data = yaml.safe_load(f)
401
+ except Exception:
402
+ return set()
403
+
404
+ if not isinstance(data, dict):
405
+ return set()
406
+
407
+ return {key for key in data if str(key).strip().lower() in UNSAFE_CONFIG_KEYS}
408
+
409
+
410
+ def key_given_on_command_line(key, argv):
411
+ dest = config_key_to_dest(key)
412
+ flag = dest.replace("_", "-")
413
+ variants = {f"--{flag}", f"--no-{flag}", f"--{dest}"}
414
+
415
+ for arg in argv or []:
416
+ if str(arg).split("=", 1)[0] in variants:
417
+ return True
418
+
419
+ return False
420
+
421
+
422
+ def sanitize_repo_config(args, parser, config_files, argv, printer=print):
423
+ """Drop execution-capable settings that came from a repo-local config file.
424
+
425
+ Values given on the command line always win, so an explicit --test-cmd is
426
+ left alone; only auto-discovered repository config is stripped.
427
+ """
428
+ blocked = {}
429
+
430
+ for fname in config_files:
431
+ if is_trusted_config_path(
432
+ fname, Path(".patch.conf.yml"), getattr(args, "config_file", None)
433
+ ):
434
+ continue
435
+ for key in unsafe_keys_in_config(fname):
436
+ if key_given_on_command_line(key, argv):
437
+ continue
438
+ blocked.setdefault(config_key_to_dest(key), set()).add(str(fname))
439
+
440
+ for dest, files in sorted(blocked.items()):
441
+ if not hasattr(args, dest):
442
+ continue
443
+ setattr(args, dest, parser.get_default(dest))
444
+ for fname in sorted(files):
445
+ printer(f"Ignoring untrusted setting '{dest}' from repository config {fname}")
446
+
447
+ if blocked:
448
+ printer("Pass --trust-repo-config to allow settings from this repository's config.")
449
+
450
+ return set(blocked)
451
+
452
+
453
+ def generate_search_path_list(default_file, git_root, command_line_file):
454
+ files = []
455
+ files.append(Path.home() / default_file) # homedir
456
+ if git_root:
457
+ files.append(Path(git_root) / default_file) # git root
458
+ files.append(default_file)
459
+ if command_line_file:
460
+ files.append(command_line_file)
461
+
462
+ resolved_files = []
463
+ for fn in files:
464
+ try:
465
+ resolved_files.append(Path(fn).resolve())
466
+ except OSError:
467
+ pass
468
+
469
+ files = resolved_files
470
+ files.reverse()
471
+ uniq = []
472
+ for fn in files:
473
+ if fn not in uniq:
474
+ uniq.append(fn)
475
+ uniq.reverse()
476
+ files = uniq
477
+ files = list(map(str, files))
478
+ files = list(dict.fromkeys(files))
479
+
480
+ return files
481
+
482
+
483
+ def register_models(git_root, model_settings_fname, io, verbose=False):
484
+ model_settings_files = generate_search_path_list(
485
+ ".patch.model.settings.yml", git_root, model_settings_fname
486
+ )
487
+
488
+ try:
489
+ files_loaded = models.register_models(model_settings_files)
490
+ if len(files_loaded) > 0:
491
+ if verbose:
492
+ io.tool_output("Loaded model settings from:")
493
+ for file_loaded in files_loaded:
494
+ io.tool_output(f" - {file_loaded}") # noqa: E221
495
+ elif verbose:
496
+ io.tool_output("No model settings files loaded")
497
+ except Exception as e:
498
+ io.tool_error(f"Error loading Patch model settings: {e}")
499
+ return 1
500
+
501
+ if verbose:
502
+ io.tool_output("Searched for model settings files:")
503
+ for file in model_settings_files:
504
+ io.tool_output(f" - {file}")
505
+
506
+ return None
507
+
508
+
509
+ def load_dotenv_files(git_root, dotenv_fname, encoding="utf-8", trust_repo=False, printer=print):
510
+ # Standard .env file search path
511
+ dotenv_files = generate_search_path_list(
512
+ ".env",
513
+ git_root,
514
+ dotenv_fname,
515
+ )
516
+
517
+ # Explicitly add the OAuth keys file to the beginning of the list
518
+ oauth_keys_file = Path.home() / ".patch" / "oauth-keys.env"
519
+ if oauth_keys_file.exists():
520
+ # Insert at the beginning so it's loaded first (and potentially overridden)
521
+ dotenv_files.insert(0, str(oauth_keys_file.resolve()))
522
+ # Remove duplicates if it somehow got included by generate_search_path_list
523
+ dotenv_files = list(dict.fromkeys(dotenv_files))
524
+
525
+ loaded = []
526
+ for fname in dotenv_files:
527
+ try:
528
+ if not Path(fname).exists():
529
+ continue
530
+
531
+ if trust_repo or is_trusted_config_path(fname, Path(".env"), dotenv_fname):
532
+ load_dotenv(fname, override=True, encoding=encoding)
533
+ else:
534
+ load_untrusted_dotenv(fname, encoding, printer)
535
+
536
+ loaded.append(fname)
537
+ except OSError as e:
538
+ print(f"OSError loading {fname}: {e}")
539
+ except Exception as e:
540
+ print(f"Error loading {fname}: {e}")
541
+ return loaded
542
+
543
+
544
+ def load_untrusted_dotenv(fname, encoding, printer=print):
545
+ """Apply a repository-local .env, minus the vars that redirect or impersonate.
546
+
547
+ Blocking only the dangerous names keeps the common case working -- a project
548
+ .env that sets a model or an API key still loads -- while removing the
549
+ exfiltration primitive (pointing *_API_BASE at an attacker endpoint).
550
+ """
551
+ blocked = []
552
+
553
+ for key, value in dotenv_values(fname, encoding=encoding).items():
554
+ if is_unsafe_env_var(key):
555
+ blocked.append(key)
556
+ continue
557
+ if value is not None:
558
+ os.environ[key] = value
559
+
560
+ for key in sorted(blocked):
561
+ printer(f"Ignoring untrusted env var '{key}' from repository .env {fname}")
562
+
563
+ if blocked:
564
+ printer("Pass --trust-repo-config to allow env vars from this repository.")
565
+
566
+ return blocked
567
+
568
+
569
+ def register_litellm_models(git_root, model_metadata_fname, io, verbose=False):
570
+ model_metadata_files = []
571
+
572
+ # Add the resource file path
573
+ resource_metadata = importlib_resources.files("patch.resources").joinpath("model-metadata.json")
574
+ model_metadata_files.append(str(resource_metadata))
575
+
576
+ model_metadata_files += generate_search_path_list(
577
+ ".patch.model.metadata.json", git_root, model_metadata_fname
578
+ )
579
+
580
+ try:
581
+ model_metadata_files_loaded = models.register_litellm_models(model_metadata_files)
582
+ if len(model_metadata_files_loaded) > 0 and verbose:
583
+ io.tool_output("Loaded model metadata from:")
584
+ for model_metadata_file in model_metadata_files_loaded:
585
+ io.tool_output(f" - {model_metadata_file}") # noqa: E221
586
+ except Exception as e:
587
+ io.tool_error(f"Error loading model metadata models: {e}")
588
+ return 1
589
+
590
+
591
+ def sanity_check_repo(repo, io):
592
+ if not repo:
593
+ return True
594
+
595
+ if not repo.repo.working_tree_dir:
596
+ io.tool_error("The git repo does not seem to have a working tree?")
597
+ return False
598
+
599
+ bad_ver = False
600
+ try:
601
+ repo.get_tracked_files()
602
+ if not repo.git_repo_error:
603
+ return True
604
+ error_msg = str(repo.git_repo_error)
605
+ except UnicodeDecodeError as exc:
606
+ error_msg = (
607
+ "Failed to read the Git repository. This issue is likely caused by a path encoded "
608
+ f'in a format different from the expected encoding "{sys.getfilesystemencoding()}".\n'
609
+ f"Internal error: {str(exc)}"
610
+ )
611
+ except ANY_GIT_ERROR as exc:
612
+ error_msg = str(exc)
613
+ bad_ver = "version in (1, 2)" in error_msg
614
+ except AssertionError as exc:
615
+ error_msg = str(exc)
616
+ bad_ver = True
617
+
618
+ if bad_ver:
619
+ io.tool_error("Patch only works with git repos with version number 1 or 2.")
620
+ io.tool_output("You may be able to convert your repo: git update-index --index-version=2")
621
+ io.tool_output("Or run patch --no-git to proceed without using git.")
622
+ io.offer_url(urls.git_index_version, "Open documentation url for more info?")
623
+ return False
624
+
625
+ io.tool_error("Unable to read git repository, it may be corrupt?")
626
+ io.tool_output(error_msg)
627
+ return False
628
+
629
+
630
+ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False):
631
+ report_uncaught_exceptions()
632
+
633
+ if argv is None:
634
+ argv = sys.argv[1:]
635
+
636
+ if git is None:
637
+ git_root = None
638
+ elif force_git_root:
639
+ git_root = force_git_root
640
+ else:
641
+ git_root = get_git_root()
642
+
643
+ conf_fname = Path(".patch.conf.yml")
644
+
645
+ default_config_files = [Path.home() / conf_fname] # homedir
646
+
647
+ if git_root:
648
+ git_conf = Path(git_root) / conf_fname # git root
649
+ if git_conf not in default_config_files:
650
+ default_config_files.append(git_conf)
651
+ try:
652
+ cwd_conf = conf_fname.resolve() # CWD
653
+ if cwd_conf not in default_config_files:
654
+ default_config_files.append(cwd_conf)
655
+ except OSError:
656
+ pass
657
+ default_config_files = list(map(str, default_config_files))
658
+
659
+ parser = get_parser(default_config_files, git_root)
660
+ try:
661
+ args, unknown = parser.parse_known_args(argv)
662
+ except AttributeError as e:
663
+ if all(word in str(e) for word in ["bool", "object", "has", "no", "attribute", "strip"]):
664
+ if check_config_files_for_yes(default_config_files):
665
+ return 1
666
+ raise e
667
+
668
+ if args.verbose:
669
+ print("Config files search order, if no --config:")
670
+ for file in default_config_files:
671
+ exists = "(exists)" if Path(file).exists() else ""
672
+ print(f" - {file} {exists}")
673
+
674
+ parser = get_parser(default_config_files, git_root)
675
+
676
+ args, unknown = parser.parse_known_args(argv)
677
+
678
+ # Read from argv and the ambient environment only. Both belong to the user,
679
+ # and neither has been touched by the repository's .env yet -- it is loaded
680
+ # just below. PATCH_TRUST_REPO_CONFIG is itself on the blocked list, so a
681
+ # repository cannot grant itself trust from its own .env or config.
682
+ trust_repo_config = "--trust-repo-config" in (argv or []) or env_flag_is_set(
683
+ "PATCH_TRUST_REPO_CONFIG"
684
+ )
685
+
686
+ # Load the .env file specified in the arguments
687
+ loaded_dotenvs = load_dotenv_files(
688
+ git_root, args.env_file, args.encoding, trust_repo=trust_repo_config
689
+ )
690
+
691
+ # Parse again to include any arguments that might have been defined in .env
692
+ args = parser.parse_args(argv)
693
+
694
+ if not trust_repo_config:
695
+ sanitize_repo_config(args, parser, default_config_files, argv)
696
+
697
+ if args.shell_completions:
698
+ # Ensure parser.prog is set for shtab, though it should be by default
699
+ parser.prog = "patch"
700
+ print(shtab.complete(parser, shell=args.shell_completions))
701
+ sys.exit(0)
702
+
703
+ if git is None:
704
+ args.git = False
705
+
706
+ if args.analytics_disable:
707
+ print("Analytics have been permanently disabled.")
708
+
709
+ if not args.verify_ssl:
710
+ import httpx
711
+
712
+ os.environ["SSL_VERIFY"] = ""
713
+ litellm._load_litellm()
714
+ litellm._lazy_module.client_session = httpx.Client(verify=False)
715
+ litellm._lazy_module.aclient_session = httpx.AsyncClient(verify=False)
716
+ # Set verify_ssl on the model_info_manager
717
+ models.model_info_manager.set_verify_ssl(False)
718
+
719
+ if args.timeout:
720
+ models.request_timeout = args.timeout
721
+
722
+ if args.dark_mode:
723
+ args.user_input_color = "#32FF32"
724
+ args.tool_error_color = "#FF3333"
725
+ args.tool_warning_color = "#FFFF00"
726
+ args.assistant_output_color = "#00FFFF"
727
+ args.code_theme = "monokai"
728
+
729
+ if args.light_mode:
730
+ args.user_input_color = "green"
731
+ args.tool_error_color = "red"
732
+ args.tool_warning_color = "#FFA500"
733
+ args.assistant_output_color = "blue"
734
+ args.code_theme = "default"
735
+
736
+ if return_coder and args.yes_always is None:
737
+ args.yes_always = True
738
+
739
+ editing_mode = EditingMode.VI if args.vim else EditingMode.EMACS
740
+
741
+ def get_io(pretty):
742
+ return InputOutput(
743
+ pretty,
744
+ args.yes_always,
745
+ args.input_history_file,
746
+ args.chat_history_file,
747
+ input=input,
748
+ output=output,
749
+ user_input_color=args.user_input_color,
750
+ tool_output_color=args.tool_output_color,
751
+ tool_warning_color=args.tool_warning_color,
752
+ tool_error_color=args.tool_error_color,
753
+ completion_menu_color=args.completion_menu_color,
754
+ completion_menu_bg_color=args.completion_menu_bg_color,
755
+ completion_menu_current_color=args.completion_menu_current_color,
756
+ completion_menu_current_bg_color=args.completion_menu_current_bg_color,
757
+ assistant_output_color=args.assistant_output_color,
758
+ code_theme=args.code_theme,
759
+ dry_run=args.dry_run,
760
+ encoding=args.encoding,
761
+ line_endings=args.line_endings,
762
+ llm_history_file=args.llm_history_file,
763
+ editingmode=editing_mode,
764
+ fancy_input=args.fancy_input,
765
+ multiline_mode=args.multiline,
766
+ notifications=args.notifications,
767
+ notifications_command=args.notifications_command,
768
+ )
769
+
770
+ io = get_io(args.pretty)
771
+ try:
772
+ io.rule()
773
+ except UnicodeEncodeError as err:
774
+ if not io.pretty:
775
+ raise err
776
+ io = get_io(False)
777
+ io.tool_warning("Terminal does not support pretty output (UnicodeDecodeError)")
778
+
779
+ # Process any environment variables set via --set-env
780
+ if args.set_env:
781
+ for env_setting in args.set_env:
782
+ try:
783
+ name, value = env_setting.split("=", 1)
784
+ os.environ[name.strip()] = value.strip()
785
+ except ValueError:
786
+ io.tool_error(f"Invalid --set-env format: {env_setting}")
787
+ io.tool_output("Format should be: ENV_VAR_NAME=value")
788
+ return 1
789
+
790
+ # Process any API keys set via --api-key
791
+ if args.api_key:
792
+ for api_setting in args.api_key:
793
+ try:
794
+ provider, key = api_setting.split("=", 1)
795
+ env_var = f"{provider.strip().upper()}_API_KEY"
796
+ os.environ[env_var] = key.strip()
797
+ except ValueError:
798
+ io.tool_error(f"Invalid --api-key format: {api_setting}")
799
+ io.tool_output("Format should be: provider=key")
800
+ return 1
801
+
802
+ if args.anthropic_api_key:
803
+ os.environ["ANTHROPIC_API_KEY"] = args.anthropic_api_key
804
+
805
+ if args.openai_api_key:
806
+ os.environ["OPENAI_API_KEY"] = args.openai_api_key
807
+
808
+ # Handle deprecated model shortcut args
809
+ handle_deprecated_model_args(args, io)
810
+ if args.openai_api_base:
811
+ os.environ["OPENAI_API_BASE"] = args.openai_api_base
812
+ if args.openai_api_version:
813
+ io.tool_warning(
814
+ "--openai-api-version is deprecated, use --set-env OPENAI_API_VERSION=<value>"
815
+ )
816
+ os.environ["OPENAI_API_VERSION"] = args.openai_api_version
817
+ if args.openai_api_type:
818
+ io.tool_warning("--openai-api-type is deprecated, use --set-env OPENAI_API_TYPE=<value>")
819
+ os.environ["OPENAI_API_TYPE"] = args.openai_api_type
820
+ if args.openai_organization_id:
821
+ io.tool_warning(
822
+ "--openai-organization-id is deprecated, use --set-env OPENAI_ORGANIZATION=<value>"
823
+ )
824
+ os.environ["OPENAI_ORGANIZATION"] = args.openai_organization_id
825
+
826
+ analytics = Analytics(
827
+ logfile=args.analytics_log,
828
+ permanently_disable=args.analytics_disable,
829
+ posthog_host=args.analytics_posthog_host,
830
+ posthog_project_api_key=args.analytics_posthog_project_api_key,
831
+ )
832
+ if args.analytics is not False:
833
+ if analytics.need_to_ask(args.analytics):
834
+ io.tool_output(
835
+ "Patch respects your privacy and never collects your code, chat messages, keys or"
836
+ " personal info."
837
+ )
838
+ io.tool_output(f"For more info: {urls.analytics}")
839
+ disable = not io.confirm_ask(
840
+ "Allow collection of anonymous analytics to help improve Patch?"
841
+ )
842
+
843
+ analytics.asked_opt_in = True
844
+ if disable:
845
+ analytics.disable(permanently=True)
846
+ io.tool_output("Analytics have been permanently disabled.")
847
+
848
+ analytics.save_data()
849
+ io.tool_output()
850
+
851
+ # This is a no-op if the user has opted out
852
+ analytics.enable()
853
+
854
+ analytics.event("launched")
855
+
856
+ if args.gui and not return_coder:
857
+ if not check_streamlit_install(io):
858
+ analytics.event("exit", reason="Streamlit not installed")
859
+ return
860
+ analytics.event("gui session")
861
+ launch_gui(argv)
862
+ analytics.event("exit", reason="GUI session ended")
863
+ return
864
+
865
+ if args.verbose:
866
+ for fname in loaded_dotenvs:
867
+ io.tool_output(f"Loaded {fname}")
868
+
869
+ all_files = args.files + (args.file or [])
870
+ fnames = [str(Path(fn).resolve()) for fn in all_files]
871
+ read_only_fnames = []
872
+ for fn in args.read or []:
873
+ path = Path(fn).expanduser().resolve()
874
+ if path.is_dir():
875
+ read_only_fnames.extend(str(f) for f in path.rglob("*") if f.is_file())
876
+ else:
877
+ read_only_fnames.append(str(path))
878
+
879
+ if len(all_files) > 1:
880
+ good = True
881
+ for fname in all_files:
882
+ if Path(fname).is_dir():
883
+ io.tool_error(f"{fname} is a directory, not provided alone.")
884
+ good = False
885
+ if not good:
886
+ io.tool_output(
887
+ "Provide either a single directory of a git repo, or a list of one or more files."
888
+ )
889
+ analytics.event("exit", reason="Invalid directory input")
890
+ return 1
891
+
892
+ git_dname = None
893
+ if len(all_files) == 1:
894
+ if Path(all_files[0]).is_dir():
895
+ if args.git:
896
+ git_dname = str(Path(all_files[0]).resolve())
897
+ fnames = []
898
+ else:
899
+ io.tool_error(f"{all_files[0]} is a directory, but --no-git selected.")
900
+ analytics.event("exit", reason="Directory with --no-git")
901
+ return 1
902
+
903
+ # We can't know the git repo for sure until after parsing the args.
904
+ # If we guessed wrong, reparse because that changes things like
905
+ # the location of the config.yml and history files.
906
+ if args.git and not force_git_root and git is not None:
907
+ right_repo_root = guessed_wrong_repo(io, git_root, fnames, git_dname)
908
+ if right_repo_root:
909
+ analytics.event("exit", reason="Recursing with correct repo")
910
+ return main(argv, input, output, right_repo_root, return_coder=return_coder)
911
+
912
+ if args.just_check_update:
913
+ update_available = check_version(io, just_check=True, verbose=args.verbose)
914
+ analytics.event("exit", reason="Just checking update")
915
+ return 0 if not update_available else 1
916
+
917
+ if args.install_main_branch:
918
+ success = install_from_main_branch(io)
919
+ analytics.event("exit", reason="Installed main branch")
920
+ return 0 if success else 1
921
+
922
+ if args.upgrade:
923
+ success = install_upgrade(io)
924
+ analytics.event("exit", reason="Upgrade completed")
925
+ return 0 if success else 1
926
+
927
+ if args.check_update:
928
+ check_version(io, verbose=args.verbose)
929
+
930
+ if args.git:
931
+ git_root = setup_git(git_root, io)
932
+ if args.gitignore:
933
+ check_gitignore(git_root, io)
934
+
935
+ if args.verbose:
936
+ show = format_settings(parser, args)
937
+ io.tool_output(show)
938
+
939
+ cmd_line = " ".join(sys.argv)
940
+ cmd_line = scrub_sensitive_info(args, cmd_line)
941
+ io.tool_output(cmd_line, log_only=True)
942
+
943
+ is_first_run = is_first_run_of_new_version(io, verbose=args.verbose)
944
+ check_and_load_imports(io, is_first_run, verbose=args.verbose)
945
+
946
+ if register_models(git_root, args.model_settings_file, io, verbose=args.verbose):
947
+ return 1
948
+ if register_litellm_models(git_root, args.model_metadata_file, io, verbose=args.verbose):
949
+ return 1
950
+
951
+ if args.list_models:
952
+ models.print_matching_models(io, args.list_models)
953
+ analytics.event("exit", reason="Listed models")
954
+ return 0
955
+
956
+ # Process any command line aliases
957
+ if args.alias:
958
+ for alias_def in args.alias:
959
+ # Split on first colon only
960
+ parts = alias_def.split(":", 1)
961
+ if len(parts) != 2:
962
+ io.tool_error(f"Invalid alias format: {alias_def}")
963
+ io.tool_output("Format should be: alias:model-name")
964
+ analytics.event("exit", reason="Invalid alias format error")
965
+ return 1
966
+ alias, model = parts
967
+ models.MODEL_ALIASES[alias.strip()] = model.strip()
968
+
969
+ selected_model_name = select_default_model(args, io, analytics)
970
+ if not selected_model_name:
971
+ # Error message and analytics event are handled within select_default_model
972
+ # It might have already offered OAuth if no model/keys were found.
973
+ # If it failed here, we exit.
974
+ return 1
975
+ args.model = selected_model_name # Update args with the selected model
976
+
977
+ # Check if an OpenRouter model was selected/specified but the key is missing
978
+ if args.model.startswith("openrouter/") and not os.environ.get("OPENROUTER_API_KEY"):
979
+ io.tool_warning(
980
+ f"The specified model '{args.model}' requires an OpenRouter API key, which was not"
981
+ " found."
982
+ )
983
+ # Attempt OAuth flow because the specific model needs it
984
+ if offer_openrouter_oauth(io, analytics):
985
+ # OAuth succeeded, the key should now be in os.environ.
986
+ # Check if the key is now present after the flow.
987
+ if os.environ.get("OPENROUTER_API_KEY"):
988
+ io.tool_output(
989
+ "OpenRouter successfully connected."
990
+ ) # Inform user connection worked
991
+ else:
992
+ # This case should ideally not happen if offer_openrouter_oauth succeeded
993
+ # but check defensively.
994
+ io.tool_error(
995
+ "OpenRouter authentication seemed successful, but the key is still missing."
996
+ )
997
+ analytics.event(
998
+ "exit",
999
+ reason="OpenRouter key missing after successful OAuth for specified model",
1000
+ )
1001
+ return 1
1002
+ else:
1003
+ # OAuth failed or was declined by the user
1004
+ io.tool_error(
1005
+ f"Unable to proceed without an OpenRouter API key for model '{args.model}'."
1006
+ )
1007
+ io.offer_url(urls.models_and_keys, "Open documentation URL for more info?")
1008
+ analytics.event(
1009
+ "exit",
1010
+ reason="OpenRouter key missing for specified model and OAuth failed/declined",
1011
+ )
1012
+ return 1
1013
+
1014
+ main_model = models.Model(
1015
+ args.model,
1016
+ weak_model=args.weak_model,
1017
+ editor_model=args.editor_model,
1018
+ editor_edit_format=args.editor_edit_format,
1019
+ verbose=args.verbose,
1020
+ )
1021
+
1022
+ # Check if deprecated remove_reasoning is set
1023
+ if main_model.remove_reasoning is not None:
1024
+ io.tool_warning(
1025
+ "Model setting 'remove_reasoning' is deprecated, please use 'reasoning_tag' instead."
1026
+ )
1027
+
1028
+ # Set reasoning effort and thinking tokens if specified
1029
+ if args.reasoning_effort is not None:
1030
+ # Apply if check is disabled or model explicitly supports it
1031
+ if not args.check_model_accepts_settings or (
1032
+ main_model.accepts_settings and "reasoning_effort" in main_model.accepts_settings
1033
+ ):
1034
+ main_model.set_reasoning_effort(args.reasoning_effort)
1035
+
1036
+ if args.thinking_tokens is not None:
1037
+ # Apply if check is disabled or model explicitly supports it
1038
+ if not args.check_model_accepts_settings or (
1039
+ main_model.accepts_settings and "thinking_tokens" in main_model.accepts_settings
1040
+ ):
1041
+ main_model.set_thinking_tokens(args.thinking_tokens)
1042
+
1043
+ # Show warnings about unsupported settings that are being ignored
1044
+ if args.check_model_accepts_settings:
1045
+ settings_to_check = [
1046
+ {"arg": args.reasoning_effort, "name": "reasoning_effort"},
1047
+ {"arg": args.thinking_tokens, "name": "thinking_tokens"},
1048
+ ]
1049
+
1050
+ for setting in settings_to_check:
1051
+ if setting["arg"] is not None and (
1052
+ not main_model.accepts_settings
1053
+ or setting["name"] not in main_model.accepts_settings
1054
+ ):
1055
+ io.tool_warning(
1056
+ f"Warning: {main_model.name} does not support '{setting['name']}', ignoring."
1057
+ )
1058
+ io.tool_output(
1059
+ f"Use --no-check-model-accepts-settings to force the '{setting['name']}'"
1060
+ " setting."
1061
+ )
1062
+
1063
+ if args.copy_paste and args.edit_format is None:
1064
+ if main_model.edit_format in ("diff", "whole", "diff-fenced"):
1065
+ main_model.edit_format = "editor-" + main_model.edit_format
1066
+
1067
+ if args.verbose:
1068
+ io.tool_output("Model metadata:")
1069
+ io.tool_output(json.dumps(main_model.info, indent=4))
1070
+
1071
+ io.tool_output("Model settings:")
1072
+ for attr in sorted(fields(ModelSettings), key=lambda x: x.name):
1073
+ val = getattr(main_model, attr.name)
1074
+ val = json.dumps(val, indent=4)
1075
+ io.tool_output(f"{attr.name}: {val}")
1076
+
1077
+ lint_cmds = parse_lint_cmds(args.lint_cmd, io)
1078
+ if lint_cmds is None:
1079
+ analytics.event("exit", reason="Invalid lint command format")
1080
+ return 1
1081
+
1082
+ if args.show_model_warnings:
1083
+ problem = models.sanity_check_models(io, main_model)
1084
+ if problem:
1085
+ analytics.event("model warning", main_model=main_model)
1086
+ io.tool_output("You can skip this check with --no-show-model-warnings")
1087
+
1088
+ try:
1089
+ io.offer_url(urls.model_warnings, "Open documentation url for more info?")
1090
+ io.tool_output()
1091
+ except KeyboardInterrupt:
1092
+ analytics.event("exit", reason="Keyboard interrupt during model warnings")
1093
+ return 1
1094
+
1095
+ repo = None
1096
+ if args.git:
1097
+ try:
1098
+ repo = GitRepo(
1099
+ io,
1100
+ fnames,
1101
+ git_dname,
1102
+ args.patchignore,
1103
+ models=main_model.commit_message_models(),
1104
+ attribute_author=args.attribute_author,
1105
+ attribute_committer=args.attribute_committer,
1106
+ attribute_commit_message_author=args.attribute_commit_message_author,
1107
+ attribute_commit_message_committer=args.attribute_commit_message_committer,
1108
+ commit_prompt=args.commit_prompt,
1109
+ subtree_only=args.subtree_only,
1110
+ git_commit_verify=args.git_commit_verify,
1111
+ attribute_co_authored_by=args.attribute_co_authored_by, # Pass the arg
1112
+ )
1113
+ except FileNotFoundError:
1114
+ pass
1115
+
1116
+ if not args.skip_sanity_check_repo:
1117
+ if not sanity_check_repo(repo, io):
1118
+ analytics.event("exit", reason="Repository sanity check failed")
1119
+ return 1
1120
+
1121
+ if repo and not args.skip_sanity_check_repo:
1122
+ num_files = len(repo.get_tracked_files())
1123
+ analytics.event("repo", num_files=num_files)
1124
+ else:
1125
+ analytics.event("no-repo")
1126
+
1127
+ commands = Commands(
1128
+ io,
1129
+ None,
1130
+ voice_language=args.voice_language,
1131
+ voice_input_device=args.voice_input_device,
1132
+ voice_format=args.voice_format,
1133
+ verify_ssl=args.verify_ssl,
1134
+ args=args,
1135
+ parser=parser,
1136
+ verbose=args.verbose,
1137
+ editor=args.editor,
1138
+ original_read_only_fnames=read_only_fnames,
1139
+ )
1140
+
1141
+ summarizer = ChatSummary(
1142
+ [main_model.weak_model, main_model],
1143
+ args.max_chat_history_tokens or main_model.max_chat_history_tokens,
1144
+ )
1145
+
1146
+ if args.cache_prompts and args.map_refresh == "auto":
1147
+ args.map_refresh = "files"
1148
+
1149
+ if not main_model.streaming:
1150
+ if args.stream:
1151
+ io.tool_warning(
1152
+ f"Warning: Streaming is not supported by {main_model.name}. Disabling streaming."
1153
+ )
1154
+ args.stream = False
1155
+
1156
+ if args.map_tokens is None:
1157
+ map_tokens = main_model.get_repo_map_tokens()
1158
+ else:
1159
+ map_tokens = args.map_tokens
1160
+
1161
+ # Track auto-commits configuration
1162
+ analytics.event("auto_commits", enabled=bool(args.auto_commits))
1163
+
1164
+ try:
1165
+ coder = Coder.create(
1166
+ main_model=main_model,
1167
+ edit_format=args.edit_format,
1168
+ io=io,
1169
+ repo=repo,
1170
+ fnames=fnames,
1171
+ read_only_fnames=read_only_fnames,
1172
+ show_diffs=args.show_diffs,
1173
+ auto_commits=args.auto_commits,
1174
+ dirty_commits=args.dirty_commits,
1175
+ dry_run=args.dry_run,
1176
+ map_tokens=map_tokens,
1177
+ verbose=args.verbose,
1178
+ stream=args.stream,
1179
+ use_git=args.git,
1180
+ restore_chat_history=args.restore_chat_history,
1181
+ auto_lint=args.auto_lint,
1182
+ auto_test=args.auto_test,
1183
+ lint_cmds=lint_cmds,
1184
+ test_cmd=args.test_cmd,
1185
+ commands=commands,
1186
+ summarizer=summarizer,
1187
+ analytics=analytics,
1188
+ map_refresh=args.map_refresh,
1189
+ cache_prompts=args.cache_prompts,
1190
+ map_mul_no_files=args.map_multiplier_no_files,
1191
+ num_cache_warming_pings=args.cache_keepalive_pings,
1192
+ suggest_shell_commands=args.suggest_shell_commands,
1193
+ chat_language=args.chat_language,
1194
+ commit_language=args.commit_language,
1195
+ detect_urls=args.detect_urls,
1196
+ auto_copy_context=args.copy_paste,
1197
+ auto_accept_architect=args.auto_accept_architect,
1198
+ add_gitignore_files=args.add_gitignore_files,
1199
+ )
1200
+ except UnknownEditFormat as err:
1201
+ io.tool_error(str(err))
1202
+ io.offer_url(urls.edit_formats, "Open documentation about edit formats?")
1203
+ analytics.event("exit", reason="Unknown edit format")
1204
+ return 1
1205
+ except ValueError as err:
1206
+ io.tool_error(str(err))
1207
+ analytics.event("exit", reason="ValueError during coder creation")
1208
+ return 1
1209
+
1210
+ if return_coder:
1211
+ analytics.event("exit", reason="Returning coder object")
1212
+ return coder
1213
+
1214
+ ignores = []
1215
+ if git_root:
1216
+ ignores.append(str(Path(git_root) / ".gitignore"))
1217
+ if args.patchignore:
1218
+ ignores.append(args.patchignore)
1219
+
1220
+ if args.watch_files:
1221
+ file_watcher = FileWatcher(
1222
+ coder,
1223
+ gitignores=ignores,
1224
+ verbose=args.verbose,
1225
+ analytics=analytics,
1226
+ root=str(Path.cwd()) if args.subtree_only else None,
1227
+ )
1228
+ coder.file_watcher = file_watcher
1229
+
1230
+ if args.copy_paste:
1231
+ analytics.event("copy-paste mode")
1232
+ ClipboardWatcher(coder.io, verbose=args.verbose)
1233
+
1234
+ coder.show_announcements()
1235
+
1236
+ if args.show_prompts:
1237
+ coder.cur_messages += [
1238
+ dict(role="user", content="Hello!"),
1239
+ ]
1240
+ messages = coder.format_messages().all_messages()
1241
+ utils.show_messages(messages)
1242
+ analytics.event("exit", reason="Showed prompts")
1243
+ return
1244
+
1245
+ if args.lint:
1246
+ coder.commands.cmd_lint(fnames=fnames)
1247
+
1248
+ if args.test:
1249
+ if not args.test_cmd:
1250
+ io.tool_error("No --test-cmd provided.")
1251
+ analytics.event("exit", reason="No test command provided")
1252
+ return 1
1253
+ coder.commands.cmd_test(args.test_cmd)
1254
+ if io.placeholder:
1255
+ coder.run(io.placeholder)
1256
+
1257
+ if args.commit:
1258
+ if args.dry_run:
1259
+ io.tool_output("Dry run enabled, skipping commit.")
1260
+ else:
1261
+ coder.commands.cmd_commit()
1262
+
1263
+ if args.lint or args.test or args.commit:
1264
+ analytics.event("exit", reason="Completed lint/test/commit")
1265
+ return
1266
+
1267
+ if args.show_repo_map:
1268
+ repo_map = coder.get_repo_map()
1269
+ if repo_map:
1270
+ io.tool_output(repo_map)
1271
+ analytics.event("exit", reason="Showed repo map")
1272
+ return
1273
+
1274
+ if args.apply:
1275
+ content = io.read_text(args.apply)
1276
+ if content is None:
1277
+ analytics.event("exit", reason="Failed to read apply content")
1278
+ return
1279
+ coder.partial_response_content = content
1280
+ # For testing #2879
1281
+ # from patch.coders.base_coder import all_fences
1282
+ # coder.fence = all_fences[1]
1283
+ coder.apply_updates()
1284
+ analytics.event("exit", reason="Applied updates")
1285
+ return
1286
+
1287
+ if args.apply_clipboard_edits:
1288
+ args.edit_format = main_model.editor_edit_format
1289
+ args.message = "/paste"
1290
+
1291
+ if args.show_release_notes is True:
1292
+ io.tool_output(f"Opening release notes: {urls.release_notes}")
1293
+ io.tool_output()
1294
+ webbrowser.open(urls.release_notes)
1295
+ elif args.show_release_notes is None and is_first_run:
1296
+ io.tool_output()
1297
+ io.offer_url(
1298
+ urls.release_notes,
1299
+ "Would you like to see what's new in this version?",
1300
+ allow_never=False,
1301
+ )
1302
+
1303
+ if git_root and Path.cwd().resolve() != Path(git_root).resolve():
1304
+ io.tool_warning(
1305
+ "Note: in-chat filenames are always relative to the git working dir, not the current"
1306
+ " working dir."
1307
+ )
1308
+
1309
+ io.tool_output(f"Cur working dir: {Path.cwd()}")
1310
+ io.tool_output(f"Git working dir: {git_root}")
1311
+
1312
+ if args.stream and args.cache_prompts:
1313
+ io.tool_warning("Cost estimates may be inaccurate when using streaming and caching.")
1314
+
1315
+ if args.load:
1316
+ commands.cmd_load(args.load)
1317
+
1318
+ if args.message:
1319
+ io.add_to_input_history(args.message)
1320
+ io.tool_output()
1321
+ try:
1322
+ coder.run(with_message=args.message)
1323
+ except SwitchCoder:
1324
+ pass
1325
+ analytics.event("exit", reason="Completed --message")
1326
+ return
1327
+
1328
+ if args.message_file:
1329
+ try:
1330
+ message_from_file = io.read_text(args.message_file)
1331
+ io.tool_output()
1332
+ coder.run(with_message=message_from_file)
1333
+ except FileNotFoundError:
1334
+ io.tool_error(f"Message file not found: {args.message_file}")
1335
+ analytics.event("exit", reason="Message file not found")
1336
+ return 1
1337
+ except IOError as e:
1338
+ io.tool_error(f"Error reading message file: {e}")
1339
+ analytics.event("exit", reason="Message file IO error")
1340
+ return 1
1341
+
1342
+ analytics.event("exit", reason="Completed --message-file")
1343
+ return
1344
+
1345
+ if args.exit:
1346
+ analytics.event("exit", reason="Exit flag set")
1347
+ return
1348
+
1349
+ analytics.event("cli session", main_model=main_model, edit_format=main_model.edit_format)
1350
+
1351
+ while True:
1352
+ try:
1353
+ coder.ok_to_warm_cache = bool(args.cache_keepalive_pings)
1354
+ coder.run()
1355
+ analytics.event("exit", reason="Completed main CLI coder.run")
1356
+ return
1357
+ except SwitchCoder as switch:
1358
+ coder.ok_to_warm_cache = False
1359
+
1360
+ # Set the placeholder if provided
1361
+ if hasattr(switch, "placeholder") and switch.placeholder is not None:
1362
+ io.placeholder = switch.placeholder
1363
+
1364
+ kwargs = dict(io=io, from_coder=coder)
1365
+ kwargs.update(switch.kwargs)
1366
+ if "show_announcements" in kwargs:
1367
+ del kwargs["show_announcements"]
1368
+
1369
+ coder = Coder.create(**kwargs)
1370
+
1371
+ if switch.kwargs.get("show_announcements") is not False:
1372
+ coder.show_announcements()
1373
+
1374
+
1375
+ def is_first_run_of_new_version(io, verbose=False):
1376
+ """Check if this is the first run of a new version/executable combination"""
1377
+ installs_file = Path.home() / ".patch" / "installs.json"
1378
+ key = (__version__, sys.executable)
1379
+
1380
+ # Never show notes for .dev versions
1381
+ if ".dev" in __version__:
1382
+ return False
1383
+
1384
+ if verbose:
1385
+ io.tool_output(
1386
+ f"Checking imports for version {__version__} and executable {sys.executable}"
1387
+ )
1388
+ io.tool_output(f"Installs file: {installs_file}")
1389
+
1390
+ try:
1391
+ if installs_file.exists():
1392
+ with open(installs_file, "r") as f:
1393
+ installs = json.load(f)
1394
+ if verbose:
1395
+ io.tool_output("Installs file exists and loaded")
1396
+ else:
1397
+ installs = {}
1398
+ if verbose:
1399
+ io.tool_output("Installs file does not exist, creating new dictionary")
1400
+
1401
+ is_first_run = str(key) not in installs
1402
+
1403
+ if is_first_run:
1404
+ installs[str(key)] = True
1405
+ installs_file.parent.mkdir(parents=True, exist_ok=True)
1406
+ with open(installs_file, "w") as f:
1407
+ json.dump(installs, f, indent=4)
1408
+
1409
+ return is_first_run
1410
+
1411
+ except Exception as e:
1412
+ io.tool_warning(f"Error checking version: {e}")
1413
+ if verbose:
1414
+ io.tool_output(f"Full exception details: {traceback.format_exc()}")
1415
+ return True # Safer to assume it's a first run if we hit an error
1416
+
1417
+
1418
+ def check_and_load_imports(io, is_first_run, verbose=False):
1419
+ try:
1420
+ if is_first_run:
1421
+ if verbose:
1422
+ io.tool_output(
1423
+ "First run for this version and executable, loading imports synchronously"
1424
+ )
1425
+ try:
1426
+ load_slow_imports(swallow=False)
1427
+ except Exception as err:
1428
+ io.tool_error(str(err))
1429
+ io.tool_output("Error loading required imports. Did you install Patch properly?")
1430
+ io.offer_url(urls.install_properly, "Open documentation url for more info?")
1431
+ sys.exit(1)
1432
+
1433
+ if verbose:
1434
+ io.tool_output("Imports loaded and installs file updated")
1435
+ else:
1436
+ if verbose:
1437
+ io.tool_output("Not first run, loading imports in background thread")
1438
+ thread = threading.Thread(target=load_slow_imports)
1439
+ thread.daemon = True
1440
+ thread.start()
1441
+
1442
+ except Exception as e:
1443
+ io.tool_warning(f"Error in loading imports: {e}")
1444
+ if verbose:
1445
+ io.tool_output(f"Full exception details: {traceback.format_exc()}")
1446
+
1447
+
1448
+ def load_slow_imports(swallow=True):
1449
+ # These imports are deferred in various ways to
1450
+ # improve startup time.
1451
+ # This func is called either synchronously or in a thread
1452
+ # depending on whether it's been run before for this version and executable.
1453
+
1454
+ try:
1455
+ import httpx # noqa: F401
1456
+ import litellm # noqa: F401
1457
+ import networkx # noqa: F401
1458
+ import numpy # noqa: F401
1459
+ except Exception as e:
1460
+ if not swallow:
1461
+ raise e
1462
+
1463
+
1464
+ if __name__ == "__main__":
1465
+ status = main()
1466
+ sys.exit(status)