Skip to content

Commit 299a881

Browse files
authored
Merge pull request #341 from gopar/standardize-where-to-save-command-files
Standardize where to save command files
2 parents be14599 + 0b0c1e2 commit 299a881

4 files changed

Lines changed: 131 additions & 41 deletions

File tree

aider/commands/load.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from aider.commands.utils.base_command import BaseCommand
44
from aider.commands.utils.helpers import format_command_result
5+
from aider.commands.utils.save_load_manager import SaveLoadManager
56

67

78
class LoadCommand(BaseCommand):
@@ -15,12 +16,13 @@ async def execute(cls, io, coder, args, **kwargs):
1516
io.tool_error("Please provide a filename containing commands to load.")
1617
return format_command_result(io, "load", "No filename provided")
1718

19+
manager = SaveLoadManager(coder, io)
20+
1821
try:
19-
with open(args.strip(), "r", encoding=io.encoding, errors="replace") as f:
20-
commands = f.readlines()
21-
except FileNotFoundError:
22-
io.tool_error(f"File not found: {args}")
23-
return format_command_result(io, "load", f"File not found: {args}")
22+
commands = manager.load_commands(args.strip())
23+
except FileNotFoundError as e:
24+
io.tool_error(str(e))
25+
return format_command_result(io, "load", str(e))
2426
except Exception as e:
2527
io.tool_error(f"Error reading file: {e}")
2628
return format_command_result(io, "load", f"Error reading file: {e}")
@@ -34,6 +36,7 @@ async def execute(cls, io, coder, args, **kwargs):
3436

3537
commands_instance = Commands(io, coder)
3638

39+
should_raise_at_end = None
3740
for cmd in commands:
3841
cmd = cmd.strip()
3942
if not cmd or cmd.startswith("#"):
@@ -45,21 +48,27 @@ async def execute(cls, io, coder, args, **kwargs):
4548
except Exception as e:
4649
# Handle SwitchCoder exception specifically
4750
if type(e).__name__ == "SwitchCoder":
48-
io.tool_error(
49-
f"Command '{cmd}' is only supported in interactive mode, skipping."
50-
)
51+
# SwitchCoder is raised when switching between coder types (e.g., /architect, /ask).
52+
# This is expected behavior, not an error. But this gets in the way when running `/load` so we
53+
# ignore it and continue processing remaining commands.
54+
should_raise_at_end = e
55+
continue
5156
else:
5257
# Re-raise other exceptions
5358
raise
5459

60+
if should_raise_at_end:
61+
raise should_raise_at_end
62+
5563
return format_command_result(
5664
io, "load", f"Loaded and executed commands from {args.strip()}"
5765
)
5866

5967
@classmethod
6068
def get_completions(cls, io, coder, args) -> List[str]:
6169
"""Get completion options for load command."""
62-
return []
70+
manager = SaveLoadManager(coder, io)
71+
return manager.list_files()
6372

6473
@classmethod
6574
def get_help(cls) -> str:

aider/commands/save.py

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from pathlib import Path
21
from typing import List
32

43
from aider.commands.utils.base_command import BaseCommand
54
from aider.commands.utils.helpers import format_command_result
5+
from aider.commands.utils.save_load_manager import SaveLoadManager
66

77

88
class SaveCommand(BaseCommand):
@@ -13,38 +13,16 @@ class SaveCommand(BaseCommand):
1313
async def execute(cls, io, coder, args, **kwargs):
1414
"""Execute the save command with given parameters."""
1515
if not args.strip():
16-
io.tool_error("Please provide a filename to save the commands to.")
17-
return format_command_result(io, "save", "No filename provided")
16+
return format_command_result(
17+
io, "save", "", "No filename provided to save the commands to"
18+
)
1819

19-
try:
20-
with open(args.strip(), "w", encoding=io.encoding) as f:
21-
f.write("/drop\n")
22-
# Write commands to add editable files
23-
for fname in sorted(coder.abs_fnames):
24-
rel_fname = coder.get_rel_fname(fname)
25-
f.write(f"/add {rel_fname}\n")
26-
27-
# Write commands to add read-only files
28-
for fname in sorted(coder.abs_read_only_fnames):
29-
# Use absolute path for files outside repo root, relative path for files inside
30-
if Path(fname).is_relative_to(coder.root):
31-
rel_fname = coder.get_rel_fname(fname)
32-
f.write(f"/read-only {rel_fname}\n")
33-
else:
34-
f.write(f"/read-only {fname}\n")
35-
# Write commands to add read-only stubs files
36-
for fname in sorted(coder.abs_read_only_stubs_fnames):
37-
# Use absolute path for files outside repo root, relative path for files inside
38-
if Path(fname).is_relative_to(coder.root):
39-
rel_fname = coder.get_rel_fname(fname)
40-
f.write(f"/read-only-stub {rel_fname}\n")
41-
else:
42-
f.write(f"/read-only-stub {fname}\n")
20+
manager = SaveLoadManager(coder, io)
4321

44-
io.tool_output(f"Saved commands to {args.strip()}")
45-
return format_command_result(io, "save", f"Saved commands to {args.strip()}")
22+
try:
23+
filepath = manager.save_commands(args.strip())
24+
return format_command_result(io, "save", f"Saved commands to {filepath}")
4625
except Exception as e:
47-
io.tool_error(f"Error saving commands to file: {e}")
4826
return format_command_result(io, "save", f"Error saving commands to file: {e}", e)
4927

5028
@classmethod
@@ -61,7 +39,10 @@ def get_help(cls) -> str:
6139
help_text += "\nUsage:\n"
6240
help_text += " /save <filename> # Save commands to reconstruct current chat session\n"
6341
help_text += "\nExamples:\n"
64-
help_text += " /save session.txt # Save session commands to session.txt\n"
42+
help_text += " /save session # Save to .aider/saves/session.txt\n"
43+
help_text += " /save session.txt # Save to .aider/saves/session.txt\n"
44+
help_text += " /save ./session.txt # Save to ./session.txt (explicit path)\n"
45+
help_text += " /save /tmp/session.txt # Save to /tmp/session.txt (absolute path)\n"
6546
help_text += "\nThe saved file contains commands that can be used with /load to restore\n"
6647
help_text += "the current chat session, including all editable and read-only files.\n"
6748
help_text += "The file starts with /drop to clear existing files, then adds all files.\n"

aider/commands/utils/helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ def validate_file_access(io, coder, file_path: str, require_in_chat: bool = Fals
9090
return True
9191

9292

93-
def format_command_result(io, command_name: str, success_message: str, error: Exception = None):
93+
def format_command_result(
94+
io, command_name: str, success_message: str, error: Exception | str = None
95+
):
9496
"""
9597
Format command execution result consistently.
9698
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import os
2+
from pathlib import Path
3+
from typing import List
4+
5+
6+
class SaveLoadManager:
7+
"""Manager for saving and loading command files."""
8+
9+
def __init__(self, coder, io):
10+
self.coder = coder
11+
self.io = io
12+
13+
def get_saves_directory(self) -> Path:
14+
"""Get the saves directory, creating it if necessary."""
15+
saves_dir = Path(self.coder.abs_root_path(".aider/saves"))
16+
os.makedirs(saves_dir, exist_ok=True)
17+
return saves_dir
18+
19+
def resolve_filepath(self, filename: str) -> Path:
20+
"""Resolve a filename to an absolute path, using saves directory if needed."""
21+
filepath = Path(filename)
22+
23+
# If it's a simple filename (no directory separators), save to .aider/saves/
24+
if not filepath.is_absolute() and str(filepath) == filepath.name:
25+
saves_dir = self.get_saves_directory()
26+
filepath = saves_dir / filepath
27+
28+
return filepath
29+
30+
def save_commands(self, filename: str) -> Path:
31+
"""Save commands to reconstruct the current chat session to a file."""
32+
filepath = self.resolve_filepath(filename)
33+
34+
try:
35+
# Ensure parent directory exists
36+
os.makedirs(filepath.parent, exist_ok=True)
37+
38+
with open(filepath, "w", encoding=self.io.encoding) as f:
39+
f.write("/drop\n")
40+
# Write commands to add editable files
41+
for fname in sorted(self.coder.abs_fnames):
42+
rel_fname = self.coder.get_rel_fname(fname)
43+
f.write(f"/add {rel_fname}\n")
44+
45+
# Write commands to add read-only files
46+
for fname in sorted(self.coder.abs_read_only_fnames):
47+
# Use absolute path for files outside repo root, relative path for files inside
48+
if Path(fname).is_relative_to(self.coder.root):
49+
rel_fname = self.coder.get_rel_fname(fname)
50+
f.write(f"/read-only {rel_fname}\n")
51+
else:
52+
f.write(f"/read-only {fname}\n")
53+
# Write commands to add read-only stubs files
54+
for fname in sorted(self.coder.abs_read_only_stubs_fnames):
55+
# Use absolute path for files outside repo root, relative path for files inside
56+
if Path(fname).is_relative_to(self.coder.root):
57+
rel_fname = self.coder.get_rel_fname(fname)
58+
f.write(f"/read-only-stub {rel_fname}\n")
59+
else:
60+
f.write(f"/read-only-stub {fname}\n")
61+
62+
return filepath
63+
except Exception as e:
64+
raise IOError(f"Error saving commands to file: {e}")
65+
66+
def load_commands(self, filename: str) -> List[str]:
67+
"""Load commands from a file."""
68+
filepath = self.resolve_filepath(filename)
69+
70+
try:
71+
with open(filepath, "r", encoding=self.io.encoding, errors="replace") as f:
72+
commands = f.readlines()
73+
return [
74+
cmd.strip() for cmd in commands if cmd.strip() and not cmd.strip().startswith("#")
75+
]
76+
except FileNotFoundError:
77+
raise FileNotFoundError(f"File not found: {filepath}")
78+
except Exception as e:
79+
raise IOError(f"Error reading file: {e}")
80+
81+
def list_files(self) -> List[str]:
82+
"""Return a list of all filenames (without extensions) in the saves directory.
83+
84+
Returns:
85+
List[str]: List of filenames without extensions, sorted alphabetically
86+
"""
87+
try:
88+
saves_dir = self.get_saves_directory()
89+
90+
if not saves_dir.exists():
91+
return []
92+
93+
# Get all files (not directories) in the saves directory
94+
save_files = [f.name for f in saves_dir.iterdir() if f.is_file()]
95+
return sorted(save_files)
96+
except Exception:
97+
# Return empty list on any error
98+
return []

0 commit comments

Comments
 (0)