cade-cli 0.3.3__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 (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
@@ -0,0 +1,315 @@
1
+ """Filesystem operations for CadeCoder."""
2
+
3
+ import difflib
4
+ import fnmatch
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from cadecoder.core.errors import FileOpsError, FileSystemError
10
+ from cadecoder.core.logging import log
11
+
12
+ # --- File Finding and Discovery ---
13
+
14
+
15
+ def find_files(
16
+ directory: Path,
17
+ ignore_patterns: list[str] | None = None,
18
+ include_directories: bool = False,
19
+ recursive: bool = True,
20
+ ) -> list[Path]:
21
+ """Find files (and optionally directories) recursively, respecting ignore patterns."""
22
+ found_paths: list[Path] = []
23
+ normalized_ignore_patterns = ignore_patterns or []
24
+
25
+ if not directory.is_dir():
26
+ log.warning(f"Directory not found or not accessible: {directory}")
27
+ return []
28
+
29
+ # Heuristic: patterns without typical glob wildcards are treated as potential directory names
30
+ # for the component-wise check. All patterns are still used for full/name glob matching.
31
+ ignorable_dir_components = {
32
+ pattern
33
+ for pattern in normalized_ignore_patterns
34
+ if "*" not in pattern and "?" not in pattern and "[" not in pattern and "]" not in pattern
35
+ }
36
+
37
+ items_to_iterate = list(directory.rglob("*")) if recursive else list(directory.glob("*"))
38
+
39
+ for item in items_to_iterate:
40
+ relative_path = item.relative_to(directory)
41
+
42
+ # Stage 1: Check if the item is within an explicitly named ignored directory component
43
+ is_in_ignored_dir_component = False
44
+ # Check item.name itself if it's a directory and matches a dir component pattern
45
+ if item.is_dir() and item.name in ignorable_dir_components:
46
+ is_in_ignored_dir_component = True
47
+ else: # Check parent parts if the item itself (if a dir) wasn't a direct match
48
+ # For a file like 'some_dir/another_dir/file.txt', parts are ('some_dir', 'another_dir', 'file.txt')
49
+ # We check parts[:-1] which would be ('some_dir', 'another_dir')
50
+ for part in relative_path.parts[:-1]:
51
+ if part in ignorable_dir_components:
52
+ is_in_ignored_dir_component = True
53
+ break
54
+
55
+ if is_in_ignored_dir_component:
56
+ continue
57
+
58
+ # Stage 2: Check full relative path and item name against all original glob patterns.
59
+ # This handles file-specific patterns (e.g., *.pyc) and more complex/wildcarded directory patterns.
60
+ is_glob_ignored = False
61
+ for pattern in normalized_ignore_patterns:
62
+ # Use POSIX separators for matching, even on Windows, for the full relative path
63
+ relative_path_posix = str(relative_path).replace(os.sep, "/")
64
+ if fnmatch.fnmatch(relative_path_posix, pattern) or fnmatch.fnmatch(item.name, pattern):
65
+ is_glob_ignored = True
66
+ log.debug(
67
+ f"Ignoring '{relative_path}' due to glob pattern '{pattern}' matching either full path or item name."
68
+ )
69
+ break
70
+ if is_glob_ignored:
71
+ continue
72
+
73
+ # If not ignored by any rule, add it based on its type
74
+ if item.is_file():
75
+ found_paths.append(item)
76
+ elif item.is_dir() and include_directories:
77
+ # Only add if explicitly requested and it passed all ignore checks
78
+ found_paths.append(item)
79
+
80
+ return found_paths
81
+
82
+
83
+ # --- File Reading and Writing ---
84
+
85
+
86
+ def read_text_file(file_path: Path) -> str:
87
+ """Read a text file, attempting common encodings."""
88
+ encodings = ["utf-8", "latin-1", "cp1252"]
89
+ for encoding in encodings:
90
+ try:
91
+ with file_path.open("r", encoding=encoding) as f:
92
+ return f.read()
93
+ except UnicodeDecodeError:
94
+ continue
95
+ except Exception as e:
96
+ log.warning(f"Error reading file {file_path} with encoding {encoding}: {e}")
97
+ # Propagate other errors (e.g., permission denied)
98
+ raise
99
+
100
+ # If all encodings fail
101
+ log.warning(f"Could not decode file {file_path} with standard encodings.")
102
+ # Return empty string or raise a custom error?
103
+ # For robustness, let's try reading as binary and decoding with replacement
104
+ try:
105
+ with file_path.open("rb") as f:
106
+ binary_content = f.read()
107
+ return binary_content.decode("utf-8", errors="replace")
108
+ except Exception as e:
109
+ log.error(f"Failed to read file {file_path} even as binary: {e}")
110
+ raise FileSystemError(f"Could not read file: {file_path}") from e
111
+
112
+
113
+ def write_text_file(file_path: Path, content: str) -> None:
114
+ """
115
+ Write text content to a file, overwriting if it exists.
116
+
117
+ Security: The target path is first resolved and verified to be within the
118
+ project's root directory (the current working directory). Any attempt to
119
+ write outside this root—whether through '..' path traversal, absolute paths,
120
+ or symlink resolution—results in a FileSystemError.
121
+ """
122
+ try:
123
+ # Resolve both the project root and the requested file path
124
+ project_root = Path.cwd().resolve()
125
+ resolved_path = file_path.expanduser().resolve()
126
+
127
+ # Ensure the resolved path is within the project root
128
+ if project_root not in resolved_path.parents and resolved_path != project_root:
129
+ raise FileSystemError(f"Refusing to write outside project root: '{file_path}'.")
130
+
131
+ # Ensure parent directory exists
132
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
133
+
134
+ # Write content to file
135
+ with resolved_path.open("w", encoding="utf-8") as f:
136
+ f.write(content)
137
+ log.debug(f"Successfully wrote content to {resolved_path}")
138
+ except FileSystemError:
139
+ # Re‑raise custom filesystem errors without modification
140
+ raise
141
+ except OSError as e:
142
+ log.error(f"Failed to write to file {file_path}: {e}")
143
+ raise FileSystemError(f"Could not write file: {file_path}") from e
144
+ except Exception as e:
145
+ log.error(f"An unexpected error occurred writing to file {file_path}: {e}")
146
+ raise FileSystemError(f"Could not write file: {file_path}") from e
147
+
148
+
149
+ # --- File Existence Checks ---
150
+
151
+
152
+ def file_exists(file_path: Path) -> bool:
153
+ """Check if a file exists and is a file."""
154
+ return file_path.is_file()
155
+
156
+
157
+ def dir_exists(dir_path: Path) -> bool:
158
+ """Check if a directory exists and is a directory."""
159
+ return dir_path.is_dir()
160
+
161
+
162
+ # --- Diff Generation ---
163
+
164
+
165
+ def _generate_diff_base(
166
+ lines1: list[str],
167
+ lines2: list[str],
168
+ fromfile: str,
169
+ tofile: str,
170
+ context_lines: int = 3,
171
+ ) -> str:
172
+ """Base function for generating unified diffs."""
173
+ try:
174
+ diff = difflib.unified_diff(
175
+ lines1,
176
+ lines2,
177
+ fromfile=fromfile,
178
+ tofile=tofile,
179
+ n=context_lines,
180
+ lineterm="", # Avoid extra newlines in diff output
181
+ )
182
+ return "\n".join(diff)
183
+ except Exception as e:
184
+ log.error(f"Failed to generate diff: {e}")
185
+ raise FileOpsError(f"Could not generate diff: {e}") from e
186
+
187
+
188
+ def generate_diff(path1: Path, path2: Path, context_lines: int = 3) -> str:
189
+ """Generates a unified diff between two files."""
190
+ try:
191
+ content1 = read_text_file(path1).splitlines()
192
+ content2 = read_text_file(path2).splitlines()
193
+ return _generate_diff_base(content1, content2, str(path1), str(path2), context_lines)
194
+ except FileOpsError:
195
+ raise
196
+ except Exception as e:
197
+ log.error(f"Failed to generate diff between {path1} and {path2}: {e}")
198
+ raise FileOpsError(f"Could not generate diff: {e}") from e
199
+
200
+
201
+ def generate_diff_from_content(
202
+ original_content: str,
203
+ new_content: str,
204
+ filename: str = "file",
205
+ context_lines: int = 3,
206
+ ) -> str:
207
+ """Generates a unified diff between two strings of content."""
208
+ lines1 = original_content.splitlines()
209
+ lines2 = new_content.splitlines()
210
+ return _generate_diff_base(
211
+ lines1,
212
+ lines2,
213
+ f"a/{filename}", # Standard diff format convention
214
+ f"b/{filename}", # Standard diff format convention
215
+ context_lines,
216
+ )
217
+
218
+
219
+ # --- Patch Application ---
220
+
221
+
222
+ def apply_patch(original_content: str, patch_content: str) -> str:
223
+ """Applies a unified diff patch to string content.
224
+
225
+ Note: This is a basic implementation using difflib. It might not handle
226
+ all patch complexities (fuzziness, binary files, etc.).
227
+ Consider using a dedicated patch library for more robustness if needed.
228
+
229
+ Args:
230
+ original_content: The original string content.
231
+ patch_content: The unified diff patch string.
232
+
233
+ Returns:
234
+ The patched string content.
235
+
236
+ Raises:
237
+ FileOpsError: If the patch cannot be applied cleanly.
238
+ """
239
+ # Basic check for patch format
240
+ if not patch_content.strip() or not patch_content.startswith(("+++", "---")):
241
+ log.warning("Patch content appears empty or invalid.")
242
+ # Decide whether to return original or raise error
243
+ # For safety, let's return original if patch is clearly empty
244
+ if not patch_content.strip():
245
+ return original_content
246
+ raise FileOpsError("Invalid patch format provided.")
247
+
248
+ # difflib.patch expects list of lines
249
+ original_lines = original_content.splitlines(keepends=True)
250
+ patch_content.splitlines(keepends=True)
251
+
252
+ # Use difflib._patch_apply which underlies the `patch` command-line tool logic
253
+ # It's an internal function but provides the core logic.
254
+ # It returns a tuple: (new_lines, boolean_list_of_success)
255
+ try:
256
+ # difflib does not provide a public patch application API.
257
+ # We'll use difflib.restore to reconstruct the new content from the diff.
258
+ # This only works for diffs generated by difflib.unified_diff or ndiff.
259
+ # We check if the patch is a unified diff and apply accordingly.
260
+
261
+ # Try to use difflib.restore for ndiff-style diffs
262
+ if patch_content.startswith("---") or patch_content.startswith("+++"):
263
+ # Unified diff: use difflib.unified_diff to get the diff, but no direct apply
264
+ # We'll use patch-like logic to apply the diff
265
+ try:
266
+ patched_lines = list(
267
+ difflib.restore(
268
+ difflib.unified_diff(
269
+ original_lines,
270
+ [], # empty, just to get the diff format
271
+ lineterm="",
272
+ ),
273
+ 2, # 2 = get the "to" file
274
+ )
275
+ )
276
+ # However, this doesn't actually apply the patch_content, so we need to use ndiff
277
+ # Instead, let's use ndiff and restore if possible
278
+ diff_lines = patch_content.splitlines(keepends=True)
279
+ patched_lines = list(difflib.restore(diff_lines, 2))
280
+ log.debug("Patch applied using difflib.restore.")
281
+ return "".join(patched_lines)
282
+ except Exception as e:
283
+ log.error(f"Error applying patch with difflib.restore: {e}")
284
+ raise FileOpsError(f"Failed during patch application process: {e}") from e
285
+ else:
286
+ log.error("Patch format not supported for difflib.restore.")
287
+ raise FileOpsError("Patch format not supported for difflib.restore.")
288
+
289
+ except Exception as e:
290
+ # Catch potential errors from the internal function
291
+ log.error(f"Error applying patch: {e}")
292
+ raise FileOpsError(f"Failed during patch application process: {e}") from e
293
+
294
+
295
+ # --- Code Extraction ---
296
+
297
+
298
+ def extract_code_from_markdown(markdown_content: str) -> str | None:
299
+ """Extracts the first code block content from markdown text.
300
+
301
+ Handles common code block markers like ``` and ```<language>.
302
+ Returns None if no code block is found.
303
+ """
304
+ # Regex to find fenced code blocks, possibly with language specifier
305
+ # It captures the content inside the first block found.
306
+ # DOTALL flag makes . match newlines.
307
+ match = re.search(r"^```(?:\w+)?\n(.*?)\n^```", markdown_content, re.MULTILINE | re.DOTALL)
308
+
309
+ if match:
310
+ return match.group(1).strip() # Return the captured group content
311
+ else:
312
+ log.debug("No markdown code block found in content.")
313
+ # Should we return the whole content if no block found?
314
+ # For now, return None if no explicit block.
315
+ return None
cadecoder/tools/git.py ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git operations for the CadeCoder chat application.
4
+ """
5
+
6
+ import pathlib
7
+ import subprocess
8
+
9
+ from cadecoder.core.logging import log
10
+
11
+
12
+ def get_project_root() -> pathlib.Path:
13
+ """Get the project root directory."""
14
+ return pathlib.Path.cwd()
15
+
16
+
17
+ PROJECT_ROOT = get_project_root()
18
+
19
+
20
+ def _sanitize_branch_name(name: str) -> str:
21
+ """Sanitizes a string to be a valid Git branch name."""
22
+ if not name:
23
+ return "unnamed-chat-branch"
24
+
25
+ name = name.replace(" ", "-").replace("/", "-")
26
+ name = name.replace("..", "-").replace(".lock", "")
27
+
28
+ sanitized = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
29
+
30
+ if sanitized.startswith("-"):
31
+ sanitized = "b-" + sanitized[1:] if len(sanitized) > 1 else "b-branch"
32
+
33
+ while "--" in sanitized:
34
+ sanitized = sanitized.replace("--", "-")
35
+
36
+ sanitized = sanitized.strip("-")
37
+
38
+ if sanitized.endswith("."):
39
+ sanitized = sanitized[:-1] + "-dot"
40
+
41
+ if not sanitized:
42
+ return "sanitized-empty-branch"
43
+
44
+ if len(sanitized) > 100:
45
+ sanitized = sanitized[:100]
46
+ if "@{" in sanitized:
47
+ sanitized = sanitized.replace("@{", "at-")
48
+
49
+ return sanitized
50
+
51
+
52
+ def run_git_command(git_command_args: list[str]) -> tuple[str, str | None]:
53
+ """
54
+ Runs a Git command and returns its output.
55
+
56
+ Args:
57
+ git_command_args: Git command and arguments (e.g., ["status", "--short"]).
58
+
59
+ Returns:
60
+ Tuple of (stdout, stderr_message). stderr_message is None on success.
61
+ """
62
+ command = ["git"] + git_command_args
63
+ command_str = " ".join(command)
64
+
65
+ try:
66
+ log.debug(f"Running git command: {command_str}")
67
+ result = subprocess.run(
68
+ command,
69
+ cwd=str(PROJECT_ROOT),
70
+ capture_output=True,
71
+ text=True,
72
+ check=False,
73
+ )
74
+
75
+ stdout_val = result.stdout.strip()
76
+
77
+ if result.returncode != 0:
78
+ err_msg = (
79
+ result.stderr.strip()
80
+ if result.stderr
81
+ else (
82
+ stdout_val
83
+ if stdout_val
84
+ else f"Command '{command_str}' failed: exit code {result.returncode}"
85
+ )
86
+ )
87
+ return stdout_val, err_msg
88
+
89
+ return stdout_val, None
90
+
91
+ except Exception as e:
92
+ log.error(f"Unexpected error running git command: {e}")
93
+ return "", f"Error running command '{command_str}': {str(e)}"
94
+
95
+
96
+ def create_or_switch_branch(chat_id: str, chat_name: str) -> tuple[str, str | None, str | None]:
97
+ """
98
+ Creates or switches to a Git branch for the chat session.
99
+
100
+ Returns:
101
+ Tuple of (message_to_display, error_message_if_any, branch_name).
102
+ """
103
+ base_name = chat_name if chat_name and chat_name.lower() != "new chat" else chat_id
104
+ branch_name = _sanitize_branch_name(f"cadechat/{base_name}")
105
+
106
+ list_stdout, _ = run_git_command(["branch", "--list", branch_name])
107
+ branch_exists_locally = branch_name in list_stdout.replace("*", "").strip().split("\n")
108
+
109
+ if branch_exists_locally:
110
+ stdout, err = run_git_command(["checkout", branch_name])
111
+ if err:
112
+ return stdout, f"Failed to switch to branch '{branch_name}': {err}", None
113
+ return f"Switched to existing branch: {branch_name}", None, branch_name
114
+ else:
115
+ stdout, err = run_git_command(["checkout", "-b", branch_name])
116
+ if err:
117
+ return stdout, f"Failed to create branch '{branch_name}': {err}", None
118
+ return f"Created and switched to new branch: {branch_name}", None, branch_name
119
+
120
+
121
+ def get_status() -> tuple[str, str | None]:
122
+ """Gets repository status using 'git status --short'."""
123
+ return run_git_command(["status", "--short"])
124
+
125
+
126
+ def stage_files(files: list[str] | None = None) -> tuple[str, str | None]:
127
+ """Stages specified files or all changes if no files are provided."""
128
+ cmd_args = ["add", "--"] + files if files else ["add", "."]
129
+ stdout, stderr = run_git_command(cmd_args)
130
+
131
+ if stderr:
132
+ return stdout, f"Error staging files: {stderr}"
133
+ if not files:
134
+ return "All changes staged.", None
135
+ return f"Staged: {', '.join(files)}", None
136
+
137
+
138
+ def _explain_pre_commit_failure(error_message: str) -> str:
139
+ """Provides a basic explanation for common pre-commit errors."""
140
+ explanation = "Pre-commit hook failed."
141
+ if "lint" in error_message.lower():
142
+ explanation += " Linting issues found. Please fix code style."
143
+ elif (
144
+ "unstaged" in error_message.lower() or "no changes added to commit" in error_message.lower()
145
+ ):
146
+ explanation += " Unstaged changes or no changes added. Stage first."
147
+ else:
148
+ explanation += " Check pre-commit scripts and error messages."
149
+ return explanation
150
+
151
+
152
+ def commit_staged_changes(message: str, max_iterations: int = 2) -> tuple[str, str | None]:
153
+ """Attempts to commit staged changes."""
154
+ stdout, stderr = run_git_command(["commit", "-m", message])
155
+
156
+ if stderr:
157
+ is_hook_failure = "hook" in stderr.lower() or "failed" in stderr.lower()
158
+ if is_hook_failure and max_iterations > 1:
159
+ explanation = _explain_pre_commit_failure(stderr)
160
+ no_v_stdout, no_v_stderr = run_git_command(["commit", "--no-verify", "-m", message])
161
+ if no_v_stderr:
162
+ full_err_msg = (
163
+ f"Initial commit failed: {stderr}\n{explanation}\n"
164
+ f"Commit with --no-verify also failed: {no_v_stderr}"
165
+ )
166
+ return stdout, full_err_msg
167
+ success_msg = (
168
+ f"Initial commit failed: {stderr}\n{explanation}\n"
169
+ f"Successfully committed with --no-verify."
170
+ )
171
+ return no_v_stdout or success_msg, None
172
+ return stdout, stderr
173
+
174
+ return stdout or "Changes committed successfully.", None
175
+
176
+
177
+ def reset_local_changes() -> tuple[str, str | None]:
178
+ """Resets uncommitted changes by stashing them."""
179
+ branch_name, _ = get_current_branch_name()
180
+ s_branch = _sanitize_branch_name(branch_name or "unknownbranch")
181
+ proj_id = PROJECT_ROOT.name
182
+ stash_msg = f"cadecoder-chat-stash/{proj_id}/{s_branch}"
183
+
184
+ stdout, stderr = run_git_command(["stash", "push", "-u", "-m", stash_msg])
185
+
186
+ if stderr:
187
+ return stdout, f"Error stashing changes: {stderr}"
188
+ if "No local changes to save" in stdout:
189
+ return "No local changes to reset (stash).", None
190
+ return f"Uncommitted changes stashed: '{stash_msg}'.", None
191
+
192
+
193
+ def get_current_branch_name() -> tuple[str, str | None]:
194
+ """Gets the current active Git branch name."""
195
+ return run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
196
+
197
+
198
+ def reset_chat():
199
+ """Use git stash to reset changes."""
200
+ stdout, stderr = run_git_command(["stash"])
201
+ if stderr:
202
+ print(f"Error during git stash: {stderr}")
203
+ else:
204
+ print("Chat context changes stashed (reset).")
205
+ return stdout, stderr
206
+
207
+
208
+ def clear_chat_context():
209
+ """Clear current chat context."""
210
+ print("Chat context cleared (SQLite history preserved).")
211
+ return "Context cleared"
212
+
213
+
214
+ def stage_command(files=None):
215
+ """Stage files command."""
216
+ return stage_files(files)
217
+
218
+
219
+ def commit_command(message):
220
+ """Commit command."""
221
+ return commit_staged_changes(message)