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.

Files changed (65) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/__init__.py +4 -0
  3. tunacode/cli/commands.py +632 -0
  4. tunacode/cli/main.py +47 -0
  5. tunacode/cli/repl.py +251 -0
  6. tunacode/configuration/__init__.py +1 -0
  7. tunacode/configuration/defaults.py +26 -0
  8. tunacode/configuration/models.py +69 -0
  9. tunacode/configuration/settings.py +32 -0
  10. tunacode/constants.py +129 -0
  11. tunacode/context.py +83 -0
  12. tunacode/core/__init__.py +0 -0
  13. tunacode/core/agents/__init__.py +0 -0
  14. tunacode/core/agents/main.py +119 -0
  15. tunacode/core/setup/__init__.py +17 -0
  16. tunacode/core/setup/agent_setup.py +41 -0
  17. tunacode/core/setup/base.py +37 -0
  18. tunacode/core/setup/config_setup.py +179 -0
  19. tunacode/core/setup/coordinator.py +45 -0
  20. tunacode/core/setup/environment_setup.py +62 -0
  21. tunacode/core/setup/git_safety_setup.py +188 -0
  22. tunacode/core/setup/undo_setup.py +32 -0
  23. tunacode/core/state.py +43 -0
  24. tunacode/core/tool_handler.py +57 -0
  25. tunacode/exceptions.py +105 -0
  26. tunacode/prompts/system.txt +71 -0
  27. tunacode/py.typed +0 -0
  28. tunacode/services/__init__.py +1 -0
  29. tunacode/services/mcp.py +86 -0
  30. tunacode/services/undo_service.py +244 -0
  31. tunacode/setup.py +50 -0
  32. tunacode/tools/__init__.py +0 -0
  33. tunacode/tools/base.py +244 -0
  34. tunacode/tools/read_file.py +89 -0
  35. tunacode/tools/run_command.py +107 -0
  36. tunacode/tools/update_file.py +117 -0
  37. tunacode/tools/write_file.py +82 -0
  38. tunacode/types.py +259 -0
  39. tunacode/ui/__init__.py +1 -0
  40. tunacode/ui/completers.py +129 -0
  41. tunacode/ui/console.py +74 -0
  42. tunacode/ui/constants.py +16 -0
  43. tunacode/ui/decorators.py +59 -0
  44. tunacode/ui/input.py +95 -0
  45. tunacode/ui/keybindings.py +27 -0
  46. tunacode/ui/lexers.py +46 -0
  47. tunacode/ui/output.py +109 -0
  48. tunacode/ui/panels.py +156 -0
  49. tunacode/ui/prompt_manager.py +117 -0
  50. tunacode/ui/tool_ui.py +187 -0
  51. tunacode/ui/validators.py +23 -0
  52. tunacode/utils/__init__.py +0 -0
  53. tunacode/utils/bm25.py +55 -0
  54. tunacode/utils/diff_utils.py +69 -0
  55. tunacode/utils/file_utils.py +41 -0
  56. tunacode/utils/ripgrep.py +17 -0
  57. tunacode/utils/system.py +336 -0
  58. tunacode/utils/text_utils.py +87 -0
  59. tunacode/utils/user_configuration.py +54 -0
  60. tunacode_cli-0.0.1.dist-info/METADATA +242 -0
  61. tunacode_cli-0.0.1.dist-info/RECORD +65 -0
  62. tunacode_cli-0.0.1.dist-info/WHEEL +5 -0
  63. tunacode_cli-0.0.1.dist-info/entry_points.txt +2 -0
  64. tunacode_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
  65. tunacode_cli-0.0.1.dist-info/top_level.txt +1 -0
@@ -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)