Skip to content

Commit a1d6121

Browse files
committed
Add command parser to handle shell command splitting on semicolons outside of quotes (good job deepseek)
1 parent f86df68 commit a1d6121

2 files changed

Lines changed: 181 additions & 2 deletions

File tree

cecli/coders/base_coder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from cecli import __version__, models, urls, utils
3939
from cecli.commands import Commands, SwitchCoderSignal
4040
from cecli.exceptions import LiteLLMExceptions
41-
from cecli.helpers import coroutines, nested
41+
from cecli.helpers import command_parser, coroutines, nested
4242
from cecli.helpers.conversation import (
4343
ConversationChunks,
4444
ConversationManager,
@@ -3847,7 +3847,7 @@ async def run_shell_commands(self):
38473847
self.commands.cmd_running_event.set() # Command finished
38483848

38493849
async def handle_shell_commands(self, commands_str, group):
3850-
commands = commands_str.strip().split(";")
3850+
commands = command_parser.split_shell_commands(commands_str)
38513851
command_count = sum(
38523852
1 for cmd in commands if cmd.strip() and not cmd.strip().startswith("#")
38533853
)

cecli/helpers/command_parser.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from typing import List
2+
3+
4+
def split_shell_commands(commands_str: str) -> List[str]:
5+
"""
6+
Split shell commands on semicolons, respecting quoted strings and heredoc boundaries.
7+
8+
Args:
9+
commands_str: String containing shell commands separated by semicolons
10+
11+
Returns:
12+
List of command strings split at semicolons outside quotes/heredocs
13+
"""
14+
if not commands_str:
15+
return []
16+
17+
commands: List[str] = []
18+
current_command = []
19+
20+
# State tracking
21+
in_single_quote = False
22+
in_double_quote = False
23+
in_heredoc = False
24+
heredoc_delimiter = ""
25+
heredoc_ignore_tabs = False # True for <<-EOF (dash before delimiter)
26+
heredoc_seen_newline = False # Have we seen a newline since entering heredoc?
27+
escaped = False
28+
29+
i = 0
30+
n = len(commands_str)
31+
32+
while i < n:
33+
char = commands_str[i]
34+
35+
if escaped:
36+
# Current character is escaped, treat it as literal
37+
current_command.append(char)
38+
escaped = False
39+
i += 1
40+
continue
41+
42+
if char == "\\":
43+
# Escape character
44+
escaped = True
45+
current_command.append(char)
46+
i += 1
47+
continue
48+
49+
if not in_heredoc:
50+
# Not in heredoc, handle quotes and semicolons
51+
if char == "'" and not in_double_quote:
52+
in_single_quote = not in_single_quote
53+
current_command.append(char)
54+
elif char == '"' and not in_single_quote:
55+
in_double_quote = not in_double_quote
56+
current_command.append(char)
57+
elif char == ";" and not in_single_quote and not in_double_quote:
58+
# Split at semicolon outside quotes
59+
command = "".join(current_command).strip()
60+
if command:
61+
commands.append(command)
62+
current_command = []
63+
else:
64+
# Check for heredoc start (<<)
65+
if char == "<" and i + 1 < n and commands_str[i + 1] == "<":
66+
# Remember position for capturing the heredoc start
67+
heredoc_start_pos = i
68+
i += 2 # Skip "<<"
69+
70+
# Check for dash (<<-)
71+
if i < n and commands_str[i] == "-":
72+
heredoc_ignore_tabs = True
73+
i += 1
74+
75+
# Skip whitespace before delimiter
76+
while i < n and commands_str[i] in (" ", "\t"):
77+
i += 1
78+
79+
# Parse delimiter (can be quoted or unquoted)
80+
delimiter_start = i
81+
82+
# Check for quotes
83+
if i < n and commands_str[i] in ("'", '"'):
84+
quote_char = commands_str[i]
85+
i += 1
86+
delimiter_start = i
87+
88+
# Find closing quote
89+
while i < n and commands_str[i] != quote_char:
90+
i += 1
91+
92+
heredoc_delimiter = commands_str[delimiter_start:i]
93+
if i < n:
94+
i += 1 # Skip closing quote
95+
else:
96+
# Unquoted delimiter - alphanumeric and underscore only
97+
while i < n and (commands_str[i].isalnum() or commands_str[i] == "_"):
98+
i += 1
99+
heredoc_delimiter = commands_str[delimiter_start:i]
100+
101+
if heredoc_delimiter:
102+
# Valid heredoc found - capture the entire heredoc start
103+
heredoc_start_text = commands_str[heredoc_start_pos:i]
104+
current_command.append(heredoc_start_text)
105+
106+
# Enter heredoc mode
107+
in_heredoc = True
108+
heredoc_seen_newline = False
109+
continue
110+
else:
111+
# Not a valid heredoc, treat as normal "<<"
112+
current_command.append("<<")
113+
continue
114+
else:
115+
current_command.append(char)
116+
else:
117+
# Inside heredoc
118+
current_command.append(char)
119+
120+
# Track if we've seen a newline (heredoc content starts after newline)
121+
if char == "\n":
122+
heredoc_seen_newline = True
123+
124+
# Look ahead to see if next line starts with delimiter
125+
j = i + 1
126+
127+
# Skip leading whitespace (tabs if heredoc_ignore_tabs)
128+
while j < n and commands_str[j] in (" ", "\t"):
129+
if heredoc_ignore_tabs and commands_str[j] == "\t":
130+
j += 1
131+
elif not heredoc_ignore_tabs:
132+
j += 1
133+
else:
134+
break
135+
136+
# Check if we have the delimiter at this position
137+
if (
138+
j + len(heredoc_delimiter) <= n
139+
and commands_str[j : j + len(heredoc_delimiter)] == heredoc_delimiter
140+
):
141+
# Check what comes after delimiter
142+
k = j + len(heredoc_delimiter)
143+
if k >= n or commands_str[k] in (" ", "\t", "\n", "\r", ";"):
144+
# Found heredoc end
145+
in_heredoc = False
146+
heredoc_delimiter = ""
147+
heredoc_ignore_tabs = False
148+
heredoc_seen_newline = False
149+
elif char == ";" and not heredoc_seen_newline:
150+
# Semicolon before seeing a newline in heredoc mode
151+
# This means heredoc start was immediately followed by semicolon
152+
# Not a real heredoc - split at this semicolon
153+
in_heredoc = False
154+
heredoc_delimiter = ""
155+
heredoc_ignore_tabs = False
156+
heredoc_seen_newline = False
157+
158+
# Remove the semicolon from current_command
159+
current_command.pop()
160+
161+
# Split at this semicolon
162+
command = "".join(current_command).strip()
163+
if command:
164+
commands.append(command)
165+
current_command = []
166+
continue
167+
168+
i += 1
169+
170+
# Add the last command if any
171+
if escaped:
172+
# Handle trailing escape character
173+
current_command.append("\\")
174+
175+
command = "".join(current_command).strip()
176+
if command:
177+
commands.append(command)
178+
179+
return commands

0 commit comments

Comments
 (0)