From 262f1a663b6246dc5b772a617d5f35f5db6ee543 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Wed, 27 May 2026 08:33:41 -0700 Subject: [PATCH 1/5] fix(add): do not create files under host attachment staging paths Headless/desktop hosts stage chat uploads under ./attachments/. When /add targets a missing file there, error instead of prompting to create path segments (e.g. after a failed image attach). Co-authored-by: Cursor --- cecli/commands/add.py | 39 +++++++++++++++++++++++++----------- tests/basic/test_commands.py | 13 ++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cecli/commands/add.py b/cecli/commands/add.py index c4a4e31d15b..292a85c96bf 100644 --- a/cecli/commands/add.py +++ b/cecli/commands/add.py @@ -13,6 +13,15 @@ from cecli.utils import is_image_file, run_fzf +def _is_chat_attachment_staging_path(rel_norm: str) -> bool: + """ + Host UIs (e.g. desktop IDEs) stage chat uploads under ``./attachments/``. + Do not offer to create missing paths there via ``/add`` — re-attach in the UI instead. + """ + parts = rel_norm.replace("\\", "/").strip("/").split("/") + return len(parts) >= 2 and parts[0].startswith(".") and parts[1] == "attachments" + + class AddCommand(BaseCommand): NORM_NAME = "add" DESCRIPTION = "Add files to the chat so cecli can edit them or review them in detail" @@ -71,19 +80,25 @@ async def execute(cls, io, coder, args, **kwargs): if len(confirm_fname) > 64: confirm_fname = f".../{os.path.basename(confirm_fname)}" - # Check if the path matches any exempt-path regex patterns + try: + rel_norm = os.path.relpath(fname, coder.root).replace("\\", "/") + except ValueError: + rel_norm = str(fname).replace("\\", "/") + exempt_paths = getattr(coder.args, "exempt_paths", None) or [] - if exempt_paths: - try: - rel_norm = os.path.relpath(fname, coder.root).replace("\\", "/") - except ValueError: - rel_norm = str(fname).replace("\\", "/") - if any(re.search(p, rel_norm) for p in exempt_paths): - io.tool_error( - f"Path '{confirm_fname}' matches an exempt-path pattern. " - "Skipping file creation." - ) - continue + if exempt_paths and any(re.search(p, rel_norm) for p in exempt_paths): + io.tool_error( + f"Path '{confirm_fname}' matches an exempt-path pattern. " + "Skipping file creation." + ) + continue + + if _is_chat_attachment_staging_path(rel_norm): + io.tool_error( + f"Attachment not found: {confirm_fname}. " + "Re-attach the file in chat instead of using /add on a staging path." + ) + continue if await io.confirm_ask( f"No files matched '{confirm_fname}'. Do you want to create this file?" diff --git a/tests/basic/test_commands.py b/tests/basic/test_commands.py index c62f8ec26fc..484e7815d03 100644 --- a/tests/basic/test_commands.py +++ b/tests/basic/test_commands.py @@ -248,6 +248,19 @@ async def test_cmd_add_skips_create_on_exempt_path(self): self.assertEqual(len(coder.abs_fnames), 0) self.assertFalse(staging.exists()) + async def test_cmd_add_skips_create_on_attachment_staging_path(self): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + from cecli.coders import Coder + + coder = await Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + staging = Path(".cecli/attachments/missing.png") + commands.execute("add", str(staging)) + + self.assertEqual(len(coder.abs_fnames), 0) + self.assertFalse(staging.exists()) + async def test_cmd_add_drop_directory(self): # Initialize the Commands and InputOutput objects io = InputOutput(pretty=False, fancy_input=False, yes=False) From c4cf96fd2bb1ec05d341cbfe001cea5f16175c6c Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Thu, 28 May 2026 11:36:45 -0700 Subject: [PATCH 2/5] fix(repomap): resolve absolute paths before existence checks Cherry-pick repomap-only hunk from 50e9b2798; omit grep hint (superseded by v0.100.5 validation pipeline / #557). Co-authored-by: Cursor --- cecli/repomap.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cecli/repomap.py b/cecli/repomap.py index 1f721ef0ca3..0afc009042d 100644 --- a/cecli/repomap.py +++ b/cecli/repomap.py @@ -350,6 +350,12 @@ def get_repo_map( "has_chat_files": bool(chat_files), } + def _resolve_abs_fname(self, fname: str) -> str: + """Normalize repo file paths for existence checks and tag parsing.""" + if os.path.isabs(fname): + return os.path.normpath(fname) + return os.path.normpath(os.path.join(self.root, fname)) + def get_rel_fname(self, fname): try: return os.path.relpath(fname, self.root) @@ -757,8 +763,9 @@ def get_ranked_tags( else: self.io.update_spinner(f"{UPDATING_REPO_MAP_MESSAGE}: {fname}") + abs_fname = self._resolve_abs_fname(fname) try: - file_ok = os.path.isfile(fname) + file_ok = os.path.isfile(abs_fname) except OSError: file_ok = False @@ -768,11 +775,12 @@ def get_ranked_tags( self.warned_files.add(fname) if skipped_missing <= 2: self.io.tool_warning( - f"Repo-map skipping missing file: {fname}" + f"Repo-map skipping missing file: {abs_fname}" " (removed on disk or not yet written)." ) continue + fname = abs_fname # dump(fname) rel_fname = self.get_rel_fname(fname) current_pers = 0.0 # Start with 0 personalization score From e5d575a508b352c92d2ec63cace0f7642b502331 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Sat, 6 Jun 2026 19:15:56 -0700 Subject: [PATCH 3/5] fix(tools): coerce ReadRange start_text/end_text to str Models sometimes pass line numbers as int; str() before strip/count ops avoids AttributeError in execute, format_output, and format_error. Co-authored-by: Cursor --- cecli/tools/read_range.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 809e0b6de97..bd8756822f4 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -154,6 +154,10 @@ def execute(cls, coder, read, **kwargs): ) continue + # Models sometimes pass line numbers as int; coerce before str ops. + range_start = str(range_start) + range_end = str(range_end) + if range_start.count("\n") > 4 or range_end.count("\n") > 4: error_outputs.append( cls.format_error( @@ -850,6 +854,9 @@ def format_output(cls, coder, mcp_server, tool_response): def format_error(cls, coder, error_text, file_path, range_start, range_end, operation_index): """Format error output for the ReadRange tool.""" + range_start = str(range_start or "") + range_end = str(range_end or "") + # Truncate range_start to first line with ellipsis if multiline start_line = (range_start or "N/A").split("\n")[0] if range_start and range_start.count("\n") > 0: From cd58f0e5a7be49744a37b5e5eb6a6578b8f74857 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Wed, 10 Jun 2026 21:07:01 -0700 Subject: [PATCH 4/5] fix(tools): ReadRange resilience for weak model inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_provided() now rejects common junk values ('N/A', 'null', 'none') that small models pass instead of real text markers - ReadRange auto-fallback: when start_text/end_text are missing or junk but the file is already in editable or read-only context, show the whole file (@000 → 000@) instead of erroring. Files not in context still get the strict error. Reduces wasted turns on quantized local models (qwen3.6:27b-q4_K_M) that struggle with ReadRange's start_text/end_text contract. --- cecli/tools/read_range.py | 40 ++++++++++++++++++++++++------------ cecli/tools/utils/helpers.py | 2 +- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index bd8756822f4..5733dd673dc 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -139,20 +139,34 @@ def execute(cls, coder, read, **kwargs): # Validate arguments for this operation if not is_provided(range_start) or not is_provided(range_end): - error_outputs.append( - cls.format_error( - coder, - ( - f"read operation {read_index + 1}: Provide both 'range_start' and" - " 'range_end'." - ), - file_path, - range_start, - range_end, - read_index, + # Auto-fallback: if the file is in editable context and markers are + # missing/junk, show the whole file instead of erroring. + abs_fnames = getattr(coder, "abs_fnames", set()) + abs_ro = getattr(coder, "abs_read_only_fnames", set()) + try: + abs_path_check = coder.abs_root_path(file_path) if file_path else None + except Exception: + abs_path_check = None + if abs_path_check and ( + abs_path_check in abs_fnames or abs_path_check in abs_ro + ): + range_start = "@000" + range_end = "000@" + else: + error_outputs.append( + cls.format_error( + coder, + ( + f"read operation {read_index + 1}: Provide both 'range_start' and" + " 'range_end'." + ), + file_path, + range_start, + range_end, + read_index, + ) ) - ) - continue + continue # Models sometimes pass line numbers as int; coerce before str ops. range_start = str(range_start) diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index e97bc28e204..499e0901e82 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -21,7 +21,7 @@ def is_provided(value, *, treat_zero_as_missing=False): """ if value is None: return False - if isinstance(value, str) and value == "": + if isinstance(value, str) and value.strip().lower() in ("", "n/a", "null", "none"): return False if treat_zero_as_missing and isinstance(value, (int, float)) and value == 0: return False From d815e249ecafa5e414c1aeb8873e10620a3f2984 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Sat, 13 Jun 2026 11:34:37 -0700 Subject: [PATCH 5/5] feat(tools): emit full tool output for frontend display - ls: emit full sorted file listing via tool_output (UI shows in collapsible section) - Command: always emit stdout/stderr via tool_output (was TUI-only) --- cecli/tools/command.py | 4 ++-- cecli/tools/ls.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index f09d32f2cdf..70ae397b664 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -287,8 +287,8 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal # Remove from background tracking since it's done BackgroundCommandManager.stop_background_command(command_key) - # Output to TUI console if TUI exists (same logic as _execute_foreground) - if coder.tui and coder.tui(): + # Emit output to frontend (tool card collapsible output) + if output_content.strip(): coder.io.tool_output(output_content, type="tool-result") if exit_code == 0: diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 89c5b791b5f..cc09966edee 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -80,6 +80,8 @@ def execute(cls, coder, path=None, **kwargs): f"📋 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" ) sorted_contents = sorted(contents) + # Emit full listing for frontend tool card (collapsible output) + coder.io.tool_output("\n".join(sorted_contents)) if len(sorted_contents) > 10: return ( f"Found {len(sorted_contents)} files: {', '.join(sorted_contents[:10])}..."