aider/aider/main.py

625 lines
18 KiB
Python

import configparser
import os
import re
import sys
import threading
from pathlib import Path
import git
from dotenv import load_dotenv
from prompt_toolkit.enums import EditingMode
from aider import __version__, models, utils
from aider.args import get_parser
from aider.coders import Coder
from aider.commands import SwitchCoder
from aider.io import InputOutput
from aider.llm import litellm # noqa: F401; properly init litellm on launch
from aider.repo import GitRepo
from aider.versioncheck import check_version
from .dump import dump # noqa: F401
def get_git_root():
"""Try and guess the git repo, since the conf.yml can be at the repo root"""
try:
repo = git.Repo(search_parent_directories=True)
return repo.working_tree_dir
except git.InvalidGitRepositoryError:
return None
def guessed_wrong_repo(io, git_root, fnames, git_dname):
"""After we parse the args, we can determine the real repo. Did we guess wrong?"""
try:
check_repo = Path(GitRepo(io, fnames, git_dname).root).resolve()
except FileNotFoundError:
return
# we had no guess, rely on the "true" repo result
if not git_root:
return str(check_repo)
git_root = Path(git_root).resolve()
if check_repo == git_root:
return
return str(check_repo)
def setup_git(git_root, io):
repo = None
if git_root:
repo = git.Repo(git_root)
elif io.confirm_ask("No git repo found, create one to track GPT's changes (recommended)?"):
git_root = str(Path.cwd().resolve())
repo = git.Repo.init(git_root)
io.tool_output("Git repository created in the current working directory.")
check_gitignore(git_root, io, False)
if not repo:
return
user_name = None
user_email = None
with repo.config_reader() as config:
try:
user_name = config.get_value("user", "name", None)
except (configparser.NoSectionError, configparser.NoOptionError):
pass
try:
user_email = config.get_value("user", "email", None)
except configparser.NoSectionError:
pass
if user_name and user_email:
return repo.working_tree_dir
with repo.config_writer() as git_config:
if not user_name:
git_config.set_value("user", "name", "Your Name")
io.tool_error('Update git name with: git config user.name "Your Name"')
if not user_email:
git_config.set_value("user", "email", "you@example.com")
io.tool_error('Update git email with: git config user.email "you@example.com"')
return repo.working_tree_dir
def check_gitignore(git_root, io, ask=True):
if not git_root:
return
try:
repo = git.Repo(git_root)
if repo.ignored(".aider"):
return
except git.exc.InvalidGitRepositoryError:
pass
pat = ".aider*"
gitignore_file = Path(git_root) / ".gitignore"
if gitignore_file.exists():
content = io.read_text(gitignore_file)
if content is None:
return
if pat in content.splitlines():
return
else:
content = ""
if ask and not io.confirm_ask(f"Add {pat} to .gitignore (recommended)?"):
return
if content and not content.endswith("\n"):
content += "\n"
content += pat + "\n"
io.write_text(gitignore_file, content)
io.tool_output(f"Added {pat} to .gitignore")
def format_settings(parser, args):
show = scrub_sensitive_info(args, parser.format_values())
# clean up the headings for consistency w/ new lines
heading_env = "Environment Variables:"
heading_defaults = "Defaults:"
if heading_env in show:
show = show.replace(heading_env, "\n" + heading_env)
show = show.replace(heading_defaults, "\n" + heading_defaults)
show += "\n"
show += "Option settings:\n"
for arg, val in sorted(vars(args).items()):
if val:
val = scrub_sensitive_info(args, str(val))
show += f" - {arg}: {val}\n" # noqa: E221
return show
def scrub_sensitive_info(args, text):
# Replace sensitive information with last 4 characters
if text and args.openai_api_key:
last_4 = args.openai_api_key[-4:]
text = text.replace(args.openai_api_key, f"...{last_4}")
if text and args.anthropic_api_key:
last_4 = args.anthropic_api_key[-4:]
text = text.replace(args.anthropic_api_key, f"...{last_4}")
return text
def check_streamlit_install(io):
return utils.check_pip_install_extra(
io,
"streamlit",
"You need to install the aider browser feature",
["aider-chat[browser]"],
)
def launch_gui(args):
from streamlit.web import cli
from aider import gui
print()
print("CONTROL-C to exit...")
target = gui.__file__
st_args = ["run", target]
st_args += [
"--browser.gatherUsageStats=false",
"--runner.magicEnabled=false",
"--server.runOnSave=false",
]
if "-dev" in __version__:
print("Watching for file changes.")
else:
st_args += [
"--global.developmentMode=false",
"--server.fileWatcherType=none",
"--client.toolbarMode=viewer", # minimal?
]
st_args += ["--"] + args
cli.main(st_args)
# from click.testing import CliRunner
# runner = CliRunner()
# from streamlit.web import bootstrap
# bootstrap.load_config_options(flag_options={})
# cli.main_run(target, args)
# sys.argv = ['streamlit', 'run', '--'] + args
def parse_lint_cmds(lint_cmds, io):
err = False
res = dict()
for lint_cmd in lint_cmds:
if re.match(r"^[a-z]+:.*", lint_cmd):
pieces = lint_cmd.split(":")
lang = pieces[0]
cmd = lint_cmd[len(lang) + 1 :]
lang = lang.strip()
else:
lang = None
cmd = lint_cmd
cmd = cmd.strip()
if cmd:
res[lang] = cmd
else:
io.tool_error(f'Unable to parse --lint-cmd "{lint_cmd}"')
io.tool_error('The arg should be "language: cmd --args ..."')
io.tool_error('For example: --lint-cmd "python: flake8 --select=E9"')
err = True
if err:
return
return res
def generate_search_path_list(default_fname, git_root, command_line_file):
files = []
default_file = Path(default_fname)
files.append(Path.home() / default_file) # homedir
if git_root:
files.append(Path(git_root) / default_file) # git root
files.append(default_file.resolve())
if command_line_file:
files.append(command_line_file)
files = [Path(fn).resolve() for fn in files]
files.reverse()
uniq = []
for fn in files:
if fn not in uniq:
uniq.append(fn)
uniq.reverse()
files = uniq
files = list(map(str, files))
files = list(dict.fromkeys(files))
return files
def register_models(git_root, model_settings_fname, io):
model_settings_files = generate_search_path_list(
".aider.model.settings.yml", git_root, model_settings_fname
)
try:
files_loaded = models.register_models(model_settings_files)
if len(files_loaded) > 0:
io.tool_output(f"Loaded {len(files_loaded)} model settings file(s)")
for file_loaded in files_loaded:
io.tool_output(f" - {file_loaded}") # noqa: E221
except Exception as e:
io.tool_error(f"Error loading aider model settings: {e}")
return 1
return None
def load_dotenv_files(git_root, dotenv_fname):
dotenv_files = generate_search_path_list(
".env",
git_root,
dotenv_fname,
)
loaded = []
for fname in dotenv_files:
if Path(fname).exists():
loaded.append(fname)
load_dotenv(fname, override=True)
return loaded
def register_litellm_models(git_root, model_metadata_fname, io):
model_metatdata_files = generate_search_path_list(
".aider.model.metadata.json", git_root, model_metadata_fname
)
try:
model_metadata_files_loaded = models.register_litellm_models(model_metatdata_files)
if len(model_metadata_files_loaded) > 0:
io.tool_output(f"Loaded {len(model_metadata_files_loaded)} model metadata file(s)")
for model_metadata_file in model_metadata_files_loaded:
io.tool_output(f" - {model_metadata_file}") # noqa: E221
except Exception as e:
io.tool_error(f"Error loading model metadata models: {e}")
return 1
def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False):
if argv is None:
argv = sys.argv[1:]
if force_git_root:
git_root = force_git_root
else:
git_root = get_git_root()
conf_fname = Path(".aider.conf.yml")
default_config_files = [conf_fname.resolve()] # CWD
if git_root:
git_conf = Path(git_root) / conf_fname # git root
if git_conf not in default_config_files:
default_config_files.append(git_conf)
default_config_files.append(Path.home() / conf_fname) # homedir
default_config_files = list(map(str, default_config_files))
parser = get_parser(default_config_files, git_root)
args, unknown = parser.parse_known_args(argv)
# Load the .env file specified in the arguments
loaded_dotenvs = load_dotenv_files(git_root, args.env_file)
# Parse again to include any arguments that might have been defined in .env
args = parser.parse_args(argv)
if not args.verify_ssl:
import httpx
litellm._load_litellm()
litellm._lazy_module.client_session = httpx.Client(verify=False)
if args.dark_mode:
args.user_input_color = "#32FF32"
args.tool_error_color = "#FF3333"
args.assistant_output_color = "#00FFFF"
args.code_theme = "monokai"
if args.light_mode:
args.user_input_color = "green"
args.tool_error_color = "red"
args.assistant_output_color = "blue"
args.code_theme = "default"
if return_coder and args.yes is None:
args.yes = True
editing_mode = EditingMode.VI if args.vim else EditingMode.EMACS
io = InputOutput(
args.pretty,
args.yes,
args.input_history_file,
args.chat_history_file,
input=input,
output=output,
user_input_color=args.user_input_color,
tool_output_color=args.tool_output_color,
tool_error_color=args.tool_error_color,
dry_run=args.dry_run,
encoding=args.encoding,
llm_history_file=args.llm_history_file,
editingmode=editing_mode,
)
if args.gui and not return_coder:
if not check_streamlit_install(io):
return
launch_gui(argv)
return
for fname in loaded_dotenvs:
io.tool_output(f"Loaded {fname}")
all_files = args.files + (args.file or [])
fnames = [str(Path(fn).resolve()) for fn in all_files]
if len(all_files) > 1:
good = True
for fname in all_files:
if Path(fname).is_dir():
io.tool_error(f"{fname} is a directory, not provided alone.")
good = False
if not good:
io.tool_error(
"Provide either a single directory of a git repo, or a list of one or more files."
)
return 1
git_dname = None
if len(all_files) == 1:
if Path(all_files[0]).is_dir():
if args.git:
git_dname = str(Path(all_files[0]).resolve())
fnames = []
else:
io.tool_error(f"{all_files[0]} is a directory, but --no-git selected.")
return 1
# We can't know the git repo for sure until after parsing the args.
# If we guessed wrong, reparse because that changes things like
# the location of the config.yml and history files.
if args.git and not force_git_root:
right_repo_root = guessed_wrong_repo(io, git_root, fnames, git_dname)
if right_repo_root:
return main(argv, input, output, right_repo_root, return_coder=return_coder)
if args.just_check_update:
update_available = check_version(io, just_check=True)
return 0 if not update_available else 1
if args.check_update:
check_version(io)
if args.models:
models.print_matching_models(io, args.models)
return 0
if args.git:
git_root = setup_git(git_root, io)
if args.gitignore:
check_gitignore(git_root, io)
if args.verbose:
show = format_settings(parser, args)
io.tool_output(show)
cmd_line = " ".join(sys.argv)
cmd_line = scrub_sensitive_info(args, cmd_line)
io.tool_output(cmd_line, log_only=True)
if args.anthropic_api_key:
os.environ["ANTHROPIC_API_KEY"] = args.anthropic_api_key
if args.openai_api_key:
os.environ["OPENAI_API_KEY"] = args.openai_api_key
if args.openai_api_base:
os.environ["OPENAI_API_BASE"] = args.openai_api_base
if args.openai_api_version:
os.environ["OPENAI_API_VERSION"] = args.openai_api_version
if args.openai_api_type:
os.environ["OPENAI_API_TYPE"] = args.openai_api_type
if args.openai_organization_id:
os.environ["OPENAI_ORGANIZATION"] = args.openai_organization_id
register_models(git_root, args.model_settings_file, io)
register_litellm_models(git_root, args.model_metadata_file, io)
if not args.model:
args.model = "gpt-4o"
if os.environ.get("ANTHROPIC_API_KEY"):
args.model = "claude-3-5-sonnet-20240620"
main_model = models.Model(args.model, weak_model=args.weak_model)
lint_cmds = parse_lint_cmds(args.lint_cmd, io)
if lint_cmds is None:
return 1
if args.show_model_warnings:
models.sanity_check_models(io, main_model)
repo = None
if args.git:
repo = GitRepo(
io,
fnames,
git_dname or ".",
args.aiderignore,
models=main_model.commit_message_models(),
attribute_author=args.attribute_author,
attribute_committer=args.attribute_committer,
attribute_commit_message=args.attribute_commit_message,
commit_prompt=args.commit_prompt,
)
from aider.commands import Commands
commands = Commands(io, None, verify_ssl=args.verify_ssl)
try:
coder = Coder.create(
main_model=main_model,
edit_format=args.edit_format,
io=io,
repo=repo,
fnames=fnames,
pretty=args.pretty,
show_diffs=args.show_diffs,
auto_commits=args.auto_commits,
dirty_commits=args.dirty_commits,
dry_run=args.dry_run,
map_tokens=args.map_tokens,
verbose=args.verbose,
assistant_output_color=args.assistant_output_color,
code_theme=args.code_theme,
stream=args.stream,
use_git=args.git,
max_chat_history_tokens=args.max_chat_history_tokens,
restore_chat_history=args.restore_chat_history,
auto_lint=args.auto_lint,
auto_test=args.auto_test,
lint_cmds=lint_cmds,
test_cmd=args.test_cmd,
verify_ssl=args.verify_ssl,
commands=commands,
)
except ValueError as err:
io.tool_error(str(err))
return 1
if return_coder:
return coder
coder.show_announcements()
if args.show_prompts:
coder.cur_messages += [
dict(role="user", content="Hello!"),
]
messages = coder.format_messages()
utils.show_messages(messages)
return
if args.lint:
coder.commands.cmd_lint(fnames=fnames)
if args.test:
if not args.test_cmd:
io.tool_error("No --test-cmd provided.")
return 1
test_errors = coder.commands.cmd_test(args.test_cmd)
if test_errors:
coder.run(test_errors)
if args.commit:
if args.dry_run:
io.tool_output("Dry run enabled, skipping commit.")
else:
coder.commands.cmd_commit()
if args.lint or args.test or args.commit:
return
if args.show_repo_map:
repo_map = coder.get_repo_map()
if repo_map:
io.tool_output(repo_map)
return
if args.apply:
content = io.read_text(args.apply)
if content is None:
return
coder.partial_response_content = content
coder.apply_updates()
return
if "VSCODE_GIT_IPC_HANDLE" in os.environ:
args.pretty = False
io.tool_output("VSCode terminal detected, pretty output has been disabled.")
io.tool_output('Use /help <question> for help, run "aider --help" to see cmd line args')
if git_root and Path.cwd().resolve() != Path(git_root).resolve():
io.tool_error(
"Note: in-chat filenames are always relative to the git working dir, not the current"
" working dir."
)
io.tool_error(f"Cur working dir: {Path.cwd()}")
io.tool_error(f"Git working dir: {git_root}")
if args.message:
io.add_to_input_history(args.message)
io.tool_output()
coder.run(with_message=args.message)
return
if args.message_file:
try:
message_from_file = io.read_text(args.message_file)
io.tool_output()
coder.run(with_message=message_from_file)
except FileNotFoundError:
io.tool_error(f"Message file not found: {args.message_file}")
return 1
except IOError as e:
io.tool_error(f"Error reading message file: {e}")
return 1
return
if args.exit:
return
thread = threading.Thread(target=load_slow_imports)
thread.daemon = True
thread.start()
while True:
try:
coder.run()
return
except SwitchCoder as switch:
coder = Coder.create(io=io, from_coder=coder, **switch.kwargs)
coder.show_announcements()
def load_slow_imports():
# These imports are deferred in various ways to
# improve startup time.
# This func is called in a thread to load them in the background
# while we wait for the user to type their first message.
try:
import httpx # noqa: F401
import litellm # noqa: F401
import networkx # noqa: F401
import numpy # noqa: F401
except Exception:
pass
if __name__ == "__main__":
status = main()
sys.exit(status)