Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``.<app>/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"
Expand Down Expand Up @@ -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?"
Expand Down
12 changes: 10 additions & 2 deletions cecli/repomap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cecli/tools/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions cecli/tools/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])}..."
Expand Down
47 changes: 34 additions & 13 deletions cecli/tools/read_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,38 @@ 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)
range_end = str(range_end)

if range_start.count("\n") > 4 or range_end.count("\n") > 4:
error_outputs.append(
Expand Down Expand Up @@ -850,6 +868,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:
Expand Down
2 changes: 1 addition & 1 deletion cecli/tools/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/basic/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading