tunacode-cli 0.0.1__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/__init__.py +0 -0
- tunacode/cli/__init__.py +4 -0
- tunacode/cli/commands.py +632 -0
- tunacode/cli/main.py +47 -0
- tunacode/cli/repl.py +251 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +26 -0
- tunacode/configuration/models.py +69 -0
- tunacode/configuration/settings.py +32 -0
- tunacode/constants.py +129 -0
- tunacode/context.py +83 -0
- tunacode/core/__init__.py +0 -0
- tunacode/core/agents/__init__.py +0 -0
- tunacode/core/agents/main.py +119 -0
- tunacode/core/setup/__init__.py +17 -0
- tunacode/core/setup/agent_setup.py +41 -0
- tunacode/core/setup/base.py +37 -0
- tunacode/core/setup/config_setup.py +179 -0
- tunacode/core/setup/coordinator.py +45 -0
- tunacode/core/setup/environment_setup.py +62 -0
- tunacode/core/setup/git_safety_setup.py +188 -0
- tunacode/core/setup/undo_setup.py +32 -0
- tunacode/core/state.py +43 -0
- tunacode/core/tool_handler.py +57 -0
- tunacode/exceptions.py +105 -0
- tunacode/prompts/system.txt +71 -0
- tunacode/py.typed +0 -0
- tunacode/services/__init__.py +1 -0
- tunacode/services/mcp.py +86 -0
- tunacode/services/undo_service.py +244 -0
- tunacode/setup.py +50 -0
- tunacode/tools/__init__.py +0 -0
- tunacode/tools/base.py +244 -0
- tunacode/tools/read_file.py +89 -0
- tunacode/tools/run_command.py +107 -0
- tunacode/tools/update_file.py +117 -0
- tunacode/tools/write_file.py +82 -0
- tunacode/types.py +259 -0
- tunacode/ui/__init__.py +1 -0
- tunacode/ui/completers.py +129 -0
- tunacode/ui/console.py +74 -0
- tunacode/ui/constants.py +16 -0
- tunacode/ui/decorators.py +59 -0
- tunacode/ui/input.py +95 -0
- tunacode/ui/keybindings.py +27 -0
- tunacode/ui/lexers.py +46 -0
- tunacode/ui/output.py +109 -0
- tunacode/ui/panels.py +156 -0
- tunacode/ui/prompt_manager.py +117 -0
- tunacode/ui/tool_ui.py +187 -0
- tunacode/ui/validators.py +23 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/bm25.py +55 -0
- tunacode/utils/diff_utils.py +69 -0
- tunacode/utils/file_utils.py +41 -0
- tunacode/utils/ripgrep.py +17 -0
- tunacode/utils/system.py +336 -0
- tunacode/utils/text_utils.py +87 -0
- tunacode/utils/user_configuration.py +54 -0
- tunacode_cli-0.0.1.dist-info/METADATA +242 -0
- tunacode_cli-0.0.1.dist-info/RECORD +65 -0
- tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
- tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
- tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
tunacode/utils/system.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: sidekick.utils.system
|
|
3
|
+
|
|
4
|
+
Provides system information and directory management utilities.
|
|
5
|
+
Handles session management, device identification, file listing
|
|
6
|
+
with gitignore support, and update checking.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..configuration.settings import ApplicationSettings
|
|
16
|
+
from ..constants import DEVICE_ID_FILE, ENV_FILE, SESSIONS_SUBDIR, SIDEKICK_HOME_DIR
|
|
17
|
+
|
|
18
|
+
# Default ignore patterns if .gitignore is not found
|
|
19
|
+
DEFAULT_IGNORE_PATTERNS = {
|
|
20
|
+
"node_modules/",
|
|
21
|
+
"env/",
|
|
22
|
+
"venv/",
|
|
23
|
+
".git/",
|
|
24
|
+
"build/",
|
|
25
|
+
"dist/",
|
|
26
|
+
"__pycache__/",
|
|
27
|
+
"*.pyc",
|
|
28
|
+
"*.pyo",
|
|
29
|
+
"*.pyd",
|
|
30
|
+
".DS_Store",
|
|
31
|
+
"Thumbs.db",
|
|
32
|
+
ENV_FILE,
|
|
33
|
+
".venv",
|
|
34
|
+
"*.egg-info",
|
|
35
|
+
".pytest_cache/",
|
|
36
|
+
".coverage",
|
|
37
|
+
"htmlcov/",
|
|
38
|
+
".tox/",
|
|
39
|
+
"coverage.xml",
|
|
40
|
+
"*.cover",
|
|
41
|
+
".idea/",
|
|
42
|
+
".vscode/",
|
|
43
|
+
"*.swp",
|
|
44
|
+
"*.swo",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_sidekick_home():
|
|
49
|
+
"""
|
|
50
|
+
Get the path to the Sidekick home directory (~/.sidekick).
|
|
51
|
+
Creates it if it doesn't exist.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Path: The path to the Sidekick home directory.
|
|
55
|
+
"""
|
|
56
|
+
home = Path.home() / SIDEKICK_HOME_DIR
|
|
57
|
+
home.mkdir(exist_ok=True)
|
|
58
|
+
return home
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_session_dir(state_manager):
|
|
62
|
+
"""
|
|
63
|
+
Get the path to the current session directory.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
state_manager: The StateManager instance containing session info.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path: The path to the current session directory.
|
|
70
|
+
"""
|
|
71
|
+
session_dir = get_sidekick_home() / SESSIONS_SUBDIR / state_manager.session.session_id
|
|
72
|
+
session_dir.mkdir(exist_ok=True, parents=True)
|
|
73
|
+
return session_dir
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_gitignore_patterns(filepath=".gitignore"):
|
|
77
|
+
"""Loads patterns from a .gitignore file."""
|
|
78
|
+
patterns = set()
|
|
79
|
+
try:
|
|
80
|
+
# Use io.open for potentially better encoding handling, though default utf-8 is usually fine
|
|
81
|
+
import io
|
|
82
|
+
|
|
83
|
+
with io.open(filepath, "r", encoding="utf-8") as f:
|
|
84
|
+
for line in f:
|
|
85
|
+
line = line.strip()
|
|
86
|
+
if line and not line.startswith("#"):
|
|
87
|
+
patterns.add(line)
|
|
88
|
+
# print(f"Loaded {len(patterns)} patterns from {filepath}") # Debug print (optional)
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
# print(f"{filepath} not found.") # Debug print (optional)
|
|
91
|
+
return None
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Error reading {filepath}: {e}")
|
|
94
|
+
return None
|
|
95
|
+
# Always ignore .git directory contents explicitly
|
|
96
|
+
patterns.add(".git/")
|
|
97
|
+
return patterns
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_ignored(rel_path, name, patterns):
|
|
101
|
+
"""
|
|
102
|
+
Checks if a given relative path or name matches any ignore patterns.
|
|
103
|
+
Mimics basic .gitignore behavior using fnmatch.
|
|
104
|
+
"""
|
|
105
|
+
if not patterns:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
# Ensure '.git' is always ignored
|
|
109
|
+
# Check both name and if the path starts with .git/
|
|
110
|
+
if name == ".git" or rel_path.startswith(".git/") or "/.git/" in rel_path:
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
path_parts = rel_path.split(os.sep)
|
|
114
|
+
|
|
115
|
+
for pattern in patterns:
|
|
116
|
+
# Normalize pattern: remove trailing slash for matching, but keep track if it was there
|
|
117
|
+
is_dir_pattern = pattern.endswith("/")
|
|
118
|
+
match_pattern = pattern.rstrip("/") if is_dir_pattern else pattern
|
|
119
|
+
|
|
120
|
+
# Remove leading slash for root-relative patterns matching logic
|
|
121
|
+
if match_pattern.startswith("/"):
|
|
122
|
+
match_pattern = match_pattern.lstrip("/")
|
|
123
|
+
# Root relative: Match only if rel_path starts with pattern
|
|
124
|
+
if fnmatch.fnmatch(rel_path, match_pattern) or fnmatch.fnmatch(
|
|
125
|
+
rel_path, match_pattern + "/*"
|
|
126
|
+
):
|
|
127
|
+
# If it was a dir pattern, ensure we are matching a dir or content within it
|
|
128
|
+
if is_dir_pattern:
|
|
129
|
+
# Check if rel_path is exactly the dir or starts with the dir path + '/'
|
|
130
|
+
if rel_path == match_pattern or rel_path.startswith(match_pattern + os.sep):
|
|
131
|
+
return True
|
|
132
|
+
else: # File pattern, direct match is enough
|
|
133
|
+
return True
|
|
134
|
+
# If root-relative, don't check further down the path parts
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# --- Non-root-relative patterns ---
|
|
138
|
+
|
|
139
|
+
# Check direct filename match (e.g., '*.log', 'config.ini')
|
|
140
|
+
if fnmatch.fnmatch(name, match_pattern):
|
|
141
|
+
# If it's a directory pattern, ensure the match corresponds to a directory segment
|
|
142
|
+
if is_dir_pattern:
|
|
143
|
+
# This check happens during directory pruning in get_cwd_files primarily.
|
|
144
|
+
# If checking a file path like 'a/b/file.txt' against 'b/', need path checks.
|
|
145
|
+
pass # Let path checks below handle dir content matching
|
|
146
|
+
else:
|
|
147
|
+
# If it's a file pattern matching the name, it's ignored.
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# Check full relative path match (e.g., 'src/*.py', 'docs/specific.txt')
|
|
151
|
+
if fnmatch.fnmatch(rel_path, match_pattern):
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
# Check if pattern matches intermediate directory names
|
|
155
|
+
# e.g. path 'a/b/c.txt', pattern 'b' (no slash) -> ignore if 'b' matches a dir name
|
|
156
|
+
# e.g. path 'a/b/c.txt', pattern 'b/' (slash) -> ignore
|
|
157
|
+
# Check if any directory component matches the pattern
|
|
158
|
+
# This is crucial for patterns like 'node_modules' or 'build/'
|
|
159
|
+
# Match pattern against any directory part
|
|
160
|
+
if (
|
|
161
|
+
is_dir_pattern or "/" not in pattern
|
|
162
|
+
): # Check patterns like 'build/' or 'node_modules' against path parts
|
|
163
|
+
# Check all parts except the last one (filename) if it's not a dir pattern itself
|
|
164
|
+
# If dir pattern ('build/'), check all parts.
|
|
165
|
+
limit = len(path_parts) if is_dir_pattern else len(path_parts) - 1
|
|
166
|
+
for i in range(limit):
|
|
167
|
+
if fnmatch.fnmatch(path_parts[i], match_pattern):
|
|
168
|
+
return True
|
|
169
|
+
# Also check the last part if it's potentially a directory being checked directly
|
|
170
|
+
if name == path_parts[-1] and fnmatch.fnmatch(name, match_pattern):
|
|
171
|
+
# This case helps match directory names passed directly during walk
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_cwd():
|
|
178
|
+
"""Returns the current working directory."""
|
|
179
|
+
return os.getcwd()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_device_id():
|
|
183
|
+
"""
|
|
184
|
+
Get the device ID from the ~/.sidekick/device_id file.
|
|
185
|
+
If the file doesn't exist, generate a new UUID and save it.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
str: The device ID as a string.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
# Get the ~/.sidekick directory
|
|
192
|
+
sidekick_home = get_sidekick_home()
|
|
193
|
+
device_id_file = sidekick_home / DEVICE_ID_FILE
|
|
194
|
+
|
|
195
|
+
# If the file exists, read the device ID
|
|
196
|
+
if device_id_file.exists():
|
|
197
|
+
device_id = device_id_file.read_text().strip()
|
|
198
|
+
if device_id:
|
|
199
|
+
return device_id
|
|
200
|
+
|
|
201
|
+
# If we got here, either the file doesn't exist or is empty
|
|
202
|
+
# Generate a new device ID
|
|
203
|
+
device_id = str(uuid.uuid4())
|
|
204
|
+
|
|
205
|
+
# Write the device ID to the file
|
|
206
|
+
device_id_file.write_text(device_id)
|
|
207
|
+
|
|
208
|
+
return device_id
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f"Error getting device ID: {e}")
|
|
211
|
+
# Return a temporary device ID if we couldn't get or save one
|
|
212
|
+
return str(uuid.uuid4())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cleanup_session(state_manager):
|
|
216
|
+
"""
|
|
217
|
+
Clean up the session directory after the CLI exits.
|
|
218
|
+
Removes the session directory completely.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
state_manager: The StateManager instance containing session info.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
bool: True if cleanup was successful, False otherwise.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
# If no session ID was generated, nothing to clean up
|
|
228
|
+
if state_manager.session.session_id is None:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
# Get the session directory using the imported function
|
|
232
|
+
session_dir = get_session_dir(state_manager)
|
|
233
|
+
|
|
234
|
+
# If the directory exists, remove it
|
|
235
|
+
if session_dir.exists():
|
|
236
|
+
import shutil
|
|
237
|
+
|
|
238
|
+
shutil.rmtree(session_dir)
|
|
239
|
+
|
|
240
|
+
return True
|
|
241
|
+
except Exception as e:
|
|
242
|
+
print(f"Error cleaning up session: {e}")
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def check_for_updates():
|
|
247
|
+
"""
|
|
248
|
+
Check if there's a newer version of sidekick-cli available on PyPI.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
tuple: (has_update, latest_version)
|
|
252
|
+
- has_update (bool): True if a newer version is available
|
|
253
|
+
- latest_version (str): The latest version available
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
app_settings = ApplicationSettings()
|
|
257
|
+
current_version = app_settings.version
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
["pip", "index", "versions", "sidekick-cli"], capture_output=True, text=True, check=True
|
|
261
|
+
)
|
|
262
|
+
output = result.stdout
|
|
263
|
+
|
|
264
|
+
if "Available versions:" in output:
|
|
265
|
+
versions_line = output.split("Available versions:")[1].strip()
|
|
266
|
+
versions = versions_line.split(", ")
|
|
267
|
+
latest_version = versions[0]
|
|
268
|
+
|
|
269
|
+
latest_version = latest_version.strip()
|
|
270
|
+
|
|
271
|
+
if latest_version > current_version:
|
|
272
|
+
return True, latest_version
|
|
273
|
+
|
|
274
|
+
# If we got here, either we're on the latest version or we couldn't parse the output
|
|
275
|
+
return False, current_version
|
|
276
|
+
except Exception:
|
|
277
|
+
return False, current_version
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def list_cwd(max_depth=3):
|
|
281
|
+
"""
|
|
282
|
+
Lists files in the current working directory up to a specified depth,
|
|
283
|
+
respecting .gitignore rules or a default ignore list.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
max_depth (int): Maximum directory depth to traverse.
|
|
287
|
+
0: only files in the current directory.
|
|
288
|
+
1: includes files in immediate subdirectories.
|
|
289
|
+
... Default is 3.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
list: A sorted list of relative file paths.
|
|
293
|
+
"""
|
|
294
|
+
ignore_patterns = _load_gitignore_patterns()
|
|
295
|
+
if ignore_patterns is None:
|
|
296
|
+
ignore_patterns = DEFAULT_IGNORE_PATTERNS
|
|
297
|
+
|
|
298
|
+
file_list = []
|
|
299
|
+
start_path = "."
|
|
300
|
+
# Ensure max_depth is non-negative
|
|
301
|
+
max_depth = max(0, max_depth)
|
|
302
|
+
|
|
303
|
+
for root, dirs, files in os.walk(start_path, topdown=True):
|
|
304
|
+
rel_root = os.path.relpath(root, start_path)
|
|
305
|
+
# Handle root case where relpath is '.'
|
|
306
|
+
if rel_root == ".":
|
|
307
|
+
rel_root = ""
|
|
308
|
+
current_depth = 0
|
|
309
|
+
else:
|
|
310
|
+
# Depth is number of separators + 1
|
|
311
|
+
current_depth = rel_root.count(os.sep) + 1
|
|
312
|
+
|
|
313
|
+
# --- Depth Pruning ---
|
|
314
|
+
if current_depth >= max_depth:
|
|
315
|
+
dirs[:] = []
|
|
316
|
+
|
|
317
|
+
# --- Directory Ignoring ---
|
|
318
|
+
original_dirs = list(dirs)
|
|
319
|
+
dirs[:] = [] # Reset dirs, only add back non-ignored ones
|
|
320
|
+
for d in original_dirs:
|
|
321
|
+
# Important: Check the directory based on its relative path
|
|
322
|
+
dir_rel_path = os.path.join(rel_root, d) if rel_root else d
|
|
323
|
+
if not _is_ignored(dir_rel_path, d, ignore_patterns):
|
|
324
|
+
dirs.append(d)
|
|
325
|
+
# else: # Optional debug print
|
|
326
|
+
# print(f"Ignoring dir: {dir_rel_path}")
|
|
327
|
+
|
|
328
|
+
# --- File Processing ---
|
|
329
|
+
if current_depth <= max_depth:
|
|
330
|
+
for f in files:
|
|
331
|
+
file_rel_path = os.path.join(rel_root, f) if rel_root else f
|
|
332
|
+
if not _is_ignored(file_rel_path, f, ignore_patterns):
|
|
333
|
+
# Standardize path separators for consistency
|
|
334
|
+
file_list.append(file_rel_path.replace(os.sep, "/"))
|
|
335
|
+
|
|
336
|
+
return sorted(file_list)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: sidekick.utils.text_utils
|
|
3
|
+
|
|
4
|
+
Provides text processing utilities.
|
|
5
|
+
Includes file extension to language mapping and key formatting functions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Set
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def key_to_title(key: str, uppercase_words: Set[str] = None) -> str:
|
|
13
|
+
"""Convert key to title, replacing underscores with spaces and capitalizing words."""
|
|
14
|
+
if uppercase_words is None:
|
|
15
|
+
uppercase_words = {"api", "id", "url"}
|
|
16
|
+
|
|
17
|
+
words = key.split("_")
|
|
18
|
+
result_words = []
|
|
19
|
+
for word in words:
|
|
20
|
+
lower_word = word.lower()
|
|
21
|
+
if lower_word in uppercase_words:
|
|
22
|
+
result_words.append(lower_word.upper())
|
|
23
|
+
elif word:
|
|
24
|
+
result_words.append(word[0].upper() + word[1:].lower())
|
|
25
|
+
else:
|
|
26
|
+
result_words.append("")
|
|
27
|
+
|
|
28
|
+
return " ".join(result_words)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ext_to_lang(path: str) -> str:
|
|
32
|
+
"""Get the language from the file extension. Default to `text` if not found."""
|
|
33
|
+
MAP = {
|
|
34
|
+
"py": "python",
|
|
35
|
+
"js": "javascript",
|
|
36
|
+
"ts": "typescript",
|
|
37
|
+
"java": "java",
|
|
38
|
+
"c": "c",
|
|
39
|
+
"cpp": "cpp",
|
|
40
|
+
"cs": "csharp",
|
|
41
|
+
"html": "html",
|
|
42
|
+
"css": "css",
|
|
43
|
+
"json": "json",
|
|
44
|
+
"yaml": "yaml",
|
|
45
|
+
"yml": "yaml",
|
|
46
|
+
}
|
|
47
|
+
ext = os.path.splitext(path)[1][1:]
|
|
48
|
+
if ext in MAP:
|
|
49
|
+
return MAP[ext]
|
|
50
|
+
return "text"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def expand_file_refs(text: str) -> str:
|
|
54
|
+
"""Expand @file references with file contents wrapped in code fences.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
text: The input text potentially containing @file references.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Text with any @file references replaced by the file's contents.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If a referenced file does not exist or is too large.
|
|
64
|
+
"""
|
|
65
|
+
import os
|
|
66
|
+
import re
|
|
67
|
+
|
|
68
|
+
from tunacode.constants import (ERROR_FILE_NOT_FOUND, ERROR_FILE_TOO_LARGE, MAX_FILE_SIZE,
|
|
69
|
+
MSG_FILE_SIZE_LIMIT)
|
|
70
|
+
|
|
71
|
+
pattern = re.compile(r"@([\w./_-]+)")
|
|
72
|
+
|
|
73
|
+
def replacer(match: re.Match) -> str:
|
|
74
|
+
path = match.group(1)
|
|
75
|
+
if not os.path.exists(path):
|
|
76
|
+
raise ValueError(ERROR_FILE_NOT_FOUND.format(filepath=path))
|
|
77
|
+
|
|
78
|
+
if os.path.getsize(path) > MAX_FILE_SIZE:
|
|
79
|
+
raise ValueError(ERROR_FILE_TOO_LARGE.format(filepath=path) + MSG_FILE_SIZE_LIMIT)
|
|
80
|
+
|
|
81
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
82
|
+
content = f.read()
|
|
83
|
+
|
|
84
|
+
lang = ext_to_lang(path)
|
|
85
|
+
return f"```{lang}\n{content}\n```"
|
|
86
|
+
|
|
87
|
+
return pattern.sub(replacer, text)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: sidekick.utils.user_configuration
|
|
3
|
+
|
|
4
|
+
Provides user configuration file management.
|
|
5
|
+
Handles loading, saving, and updating user preferences including
|
|
6
|
+
model selection and MCP server settings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from json import JSONDecodeError
|
|
11
|
+
from typing import TYPE_CHECKING, Optional
|
|
12
|
+
|
|
13
|
+
from tunacode.configuration.settings import ApplicationSettings
|
|
14
|
+
from tunacode.exceptions import ConfigurationError
|
|
15
|
+
from tunacode.types import MCPServers, ModelName, UserConfig
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from tunacode.core.state import StateManager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config() -> Optional[UserConfig]:
|
|
22
|
+
"""Load user config from file"""
|
|
23
|
+
app_settings = ApplicationSettings()
|
|
24
|
+
try:
|
|
25
|
+
with open(app_settings.paths.config_file, "r") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return None
|
|
29
|
+
except JSONDecodeError:
|
|
30
|
+
raise ConfigurationError(f"Invalid JSON in config file at {app_settings.paths.config_file}")
|
|
31
|
+
except Exception as e:
|
|
32
|
+
raise ConfigurationError(e)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_config(state_manager: "StateManager") -> bool:
|
|
36
|
+
"""Save user config to file"""
|
|
37
|
+
app_settings = ApplicationSettings()
|
|
38
|
+
try:
|
|
39
|
+
with open(app_settings.paths.config_file, "w") as f:
|
|
40
|
+
json.dump(state_manager.session.user_config, f, indent=4)
|
|
41
|
+
return True
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_mcp_servers(state_manager: "StateManager") -> MCPServers:
|
|
47
|
+
"""Retrieve MCP server configurations from user config"""
|
|
48
|
+
return state_manager.session.user_config.get("mcpServers", [])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_default_model(model_name: ModelName, state_manager: "StateManager") -> bool:
|
|
52
|
+
"""Set the default model in the user config and save"""
|
|
53
|
+
state_manager.session.user_config["default_model"] = model_name
|
|
54
|
+
return save_config(state_manager)
|