Skip to content

Commit 7fcfca0

Browse files
committed
Silently accept forward slash for special commands
* commands such as "\?" can be entered as "/?" * commands heretofore without a backslash such as "help" can be entered as "/help" Thus all special commands can be entered, uniformly, with an introductory forward slash, yet "\dt" can remain available, similar to Postgres, and plain "exit" can remain, similar to the vendor MySQL client. This helps both new learners and those with muscle memory. The only limitation to the _introductory_ forward slash is that MyCli could never use "/*" (comment-start) as a special command. For _trailing_ commands, there is also a limitation. Trailing commands such as "\edit" cannot also be entered as "/edit", since a string can be an expression, which is valid SQL: select 1 as edit group by 2/edit; SpecialCommand already had a "hidden" property, which is used here to keep the forward-slash forms from being advertised in "help", so they can be considered to be experimental. However, the plan would be to advertise the forward-slash forms quite soon in MyCli 2.x. Hidden forward-slash commands are also still completable. Some small incidental improvements are made to the detection of special commands by case, and on the acceptance of "/? <term>" for help on a term. Motivation: the forward slash has become something of an industry standard for out-of-band commands, consistent across modern applications such as Slack, Codex, or Claude. It also has a long heritage in, for example, IRC. On most keyboards, it is easier to type than the backslash.
1 parent 19d329f commit 7fcfca0

12 files changed

Lines changed: 110 additions & 34 deletions

File tree

changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
Upcoming (TBD)
2+
==============
3+
4+
Features
5+
---------
6+
* Silently accept forward slash to introduce special commands.
7+
8+
19
1.74.1 (2026/06/18)
210
==============
311

mycli/clibuffer.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from prompt_toolkit.filters import Condition, Filter
44

55
from mycli.packages.special import iocommands
6-
from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS
6+
from mycli.packages.special.main import (
7+
CASE_INSENSITIVE_COMMANDS,
8+
CASE_SENSITIVE_COMMANDS,
9+
)
710

811

912
def cli_is_multiline(mycli) -> Filter:
@@ -26,12 +29,13 @@ def _multiline_exception(text: str) -> bool:
2629
# Multi-statement favorite query is a special case. Because there will
2730
# be a semicolon separating statements, we can't consider semicolon an
2831
# EOL. Let's consider an empty line an EOL instead.
29-
if first_word.startswith("\\fs"):
32+
if first_word.startswith(("\\fs", "/fs")):
3033
return orig.endswith("\n")
3134

3235
return (
3336
# Special Command
3437
first_word.startswith("\\")
38+
or (first_word.startswith('/') and not first_word.startswith('/*'))
3539
or text.endswith((
3640
# Ended with the current delimiter (usually a semi-column)
3741
iocommands.get_current_delimiter(),
@@ -44,10 +48,10 @@ def _multiline_exception(text: str) -> bool:
4448
))
4549
or
4650
# non-backslashed special commands such as "exit" or "help" don't need semicolon
47-
first_word in SPECIAL_COMMANDS
51+
first_word in CASE_SENSITIVE_COMMANDS
4852
or
4953
# uppercase variants accepted
50-
first_word.lower() in SPECIAL_COMMANDS
54+
first_word.lower() in CASE_INSENSITIVE_COMMANDS
5155
or
5256
# just a plain enter without any text
5357
(first_word == "")

mycli/main_modes/repl.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ def complete_while_typing_filter() -> bool:
108108
last_word = text[-MIN_COMPLETION_TRIGGER:]
109109
if len(last_word) == text_len:
110110
return text_len >= MIN_COMPLETION_TRIGGER
111-
if text[:6].lower() in ['source', r'\.']:
111+
# does \. make sense with text[:6] ?
112+
if text[:6].lower() in ['source', r'\.', '/.']:
112113
# Different word characters for paths; see comment below.
113114
# In fact, it might be nice if paths had a different threshold.
114115
return not bool(re.search(r'[\s!-,:-@\[-^\{\}-]', last_word))

mycli/packages/completion_engine.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,7 @@ def suggest_type(full_text: str, text_before_cursor: str) -> list[dict[str, Any]
751751
# but the statement won't have a first token
752752
tok1 = statement.token_first()
753753
# lenient because \. will parse as two tokens
754-
if tok1 and tok1.value.startswith('\\'):
754+
if tok1 and tok1.value.startswith(('\\', '/')) and not tok1.value.startswith('/*'):
755755
return suggest_special(text_before_cursor)
756756
elif tok1:
757757
if tok1.value.lower() in SPECIAL_COMMANDS:
@@ -771,42 +771,49 @@ def suggest_special(text: str) -> list[dict[str, Any]]:
771771
# Trying to complete the special command itself
772772
return [{"type": "special"}]
773773

774-
if cmd in ("\\u", "\\r"):
774+
if cmd in ("\\u", "/u", "\\r", "/r"):
775775
return [{"type": "database"}]
776776

777-
if cmd.lower() in ('use', 'connect'):
777+
if cmd.lower() in ('use', '/use', 'connect', '/connect'):
778778
return [{'type': 'database'}]
779779

780-
if cmd in (r'\T', r'\Tr'):
780+
if cmd in (r'\T', '/T', r'\Tr', '/Tr'):
781781
return [{"type": "table_format"}]
782782

783-
if cmd.lower() in ('tableformat', 'redirectformat'):
783+
if cmd.lower() in ('tableformat', '/tableformat', 'redirectformat', '/redirectformat'):
784784
return [{"type": "table_format"}]
785785

786-
if cmd in ["\\f", "\\fs", "\\fd"]:
786+
if cmd in ["\\f", "/f", "\\fs", "/fs", "\\fd", "/fd"]:
787787
return [{"type": "favoritequery"}]
788788

789-
if cmd in ["\\dt", "\\dt+"]:
789+
if cmd in ["\\dt", "/dt", "\\dt+", "/dt+"]:
790790
return [
791791
{"type": "table", "schema": []},
792792
{"type": "view", "schema": []},
793793
{"type": "schema"},
794794
]
795795
elif cmd.lower() in [
796796
r'\.',
797+
r'/.',
797798
'source',
799+
'/source',
798800
r'\o',
801+
'/o',
799802
r'\once',
800-
r'tee',
803+
'/once',
804+
'tee',
805+
'/tee',
801806
]:
802807
return [{"type": "file_name"}]
803808
# todo: why is \edit case-sensitive?
804809
elif cmd in [
805810
r'\e',
811+
'/e',
806812
r'\edit',
813+
'/edit',
807814
]:
808815
return [{"type": "file_name"}]
809-
if cmd in ["\\llm", "\\ai"]:
816+
if cmd in ["\\llm", "/llm", "\\ai", "/ai"]:
810817
return [{"type": "llm"}]
811818

812819
return [{"type": "keyword"}, {"type": "special"}]

mycli/packages/special/iocommands.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,20 +205,19 @@ def editor_command(command: str) -> bool:
205205
:param command: string
206206
"""
207207
# special case: allow help on the \edit command
208-
if re.match(r'^([Hh][Ee][Ll][Pp])\s+(\\e|\\edit)\s*(;|\\G|\\g)?\s*$', command):
208+
if re.match(r'^/?([Hh][Ee][Ll][Pp])\s+(\\e|\\edit|/e|/edit)\s*(;|\\G|\\g)?\s*$', command):
209209
return False
210210
# It is possible to have `\e filename` or `SELECT * FROM \e`. So we check
211211
# for both conditions.
212212
return (
213-
command.strip().endswith("\\e")
214-
or command.strip().startswith("\\e ")
215-
or command.strip().endswith("\\edit")
216-
or command.strip().startswith("\\edit ")
213+
command.strip().endswith(("\\e", "\\edit"))
214+
or command.strip().startswith(("\\e ", "/e ", "\\edit ", "/edit "))
215+
or command.strip() in (("\\e", "/e", "\\edit", "/edit"))
217216
)
218217

219218

220219
def get_filename(sql: str) -> str | None:
221-
if sql.strip().startswith("\\e ") or sql.strip().startswith("\\edit "):
220+
if sql.strip().startswith(("\\e ", "/e ")) or sql.strip().startswith(("\\edit ", "/edit ")):
222221
command, _, filename = sql.partition(" ")
223222
return filename.strip() or None
224223
else:
@@ -229,6 +228,9 @@ def get_editor_query(sql: str) -> str:
229228
"""Get the query part of an editor command."""
230229
sql = sql.strip()
231230

231+
if sql in ('\\e', '/e', '\\edit', '/edit'):
232+
return ''
233+
232234
# The reason we can't simply do .strip('\e') is that it strips characters,
233235
# not a substring. So it'll strip "e" in the end of the sql also!
234236
# Ex: "select * from style\e" -> "select * from styl".
@@ -281,7 +283,7 @@ def clip_command(command: str) -> bool:
281283
"""
282284
# It is possible to have `\clip` or `SELECT * FROM \clip`. So we check
283285
# for both conditions.
284-
return command.strip().endswith("\\clip") or command.strip().startswith("\\clip")
286+
return command.strip().endswith("\\clip") or command.strip().startswith(("\\clip", "/clip"))
285287

286288

287289
def get_clip_query(sql: str) -> str:
@@ -290,7 +292,7 @@ def get_clip_query(sql: str) -> str:
290292

291293
# The reason we can't simply do .strip('\clip') is that it strips characters,
292294
# not a substring. So it'll strip "c" in the end of the sql also!
293-
pattern = re.compile(r"(^\\clip|\\clip$)")
295+
pattern = re.compile(r"(^\\clip|^/clip|\\clip$)")
294296
while pattern.search(sql):
295297
sql = pattern.sub("", sql)
296298

mycli/packages/special/llm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def handle_llm(
227227
_, command_verbosity, arg = parse_special_command(text)
228228
if not LLM_IMPORTED:
229229
raise FinishIteration(results=[SQLResult(preamble=NEED_DEPENDENCIES)])
230-
if arg.strip().lower() in ['', 'help', '?', r'\?']:
230+
if arg.strip().lower() in ['', 'help', '/help', '?', r'\?', '/?']:
231231
raise FinishIteration(results=[SQLResult(preamble=USAGE)])
232232
parts = shlex.split(arg)
233233
restart = False
@@ -286,7 +286,7 @@ def handle_llm(
286286

287287
def is_llm_command(command: str) -> bool:
288288
cmd, _, _ = parse_special_command(command)
289-
return cmd in ("\\llm", "\\ai")
289+
return cmd in ("\\llm", "/llm", "\\ai", "/ai")
290290

291291

292292
def truncate_list_elements(row: list, prompt_field_truncate: int, prompt_section_truncate: int) -> list:

mycli/packages/special/main.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ def register_special_command(
106106
case_sensitive: bool = False,
107107
aliases: list[SpecialCommandAlias] | None = None,
108108
) -> None:
109+
if command.startswith('\\'):
110+
forwardslash_command = '/' + command.removeprefix('\\')
111+
else:
112+
forwardslash_command = '/' + command
109113
cmd = command.lower() if not case_sensitive else command
114+
fcmd = forwardslash_command.lower() if not case_sensitive else forwardslash_command
110115
COMMANDS[cmd] = SpecialCommand(
111116
handler,
112117
command,
@@ -117,17 +122,36 @@ def register_special_command(
117122
case_sensitive=case_sensitive,
118123
aliases=aliases,
119124
)
125+
COMMANDS[fcmd] = SpecialCommand(
126+
handler,
127+
command,
128+
usage,
129+
description,
130+
arg_type=arg_type,
131+
hidden=True,
132+
case_sensitive=case_sensitive,
133+
aliases=aliases,
134+
)
120135
if case_sensitive:
121136
CASE_SENSITIVE_COMMANDS.add(command)
137+
CASE_SENSITIVE_COMMANDS.add(forwardslash_command)
122138
else:
123139
CASE_INSENSITIVE_COMMANDS.add(command.lower())
140+
CASE_INSENSITIVE_COMMANDS.add(forwardslash_command.lower())
124141
aliases = [] if aliases is None else aliases
125142
for alias in aliases:
143+
if alias.command.startswith('\\'):
144+
forwardslash_alias_command = '/' + alias.command.removeprefix('\\')
145+
else:
146+
forwardslash_alias_command = '/' + alias.command
126147
cmd = alias.command.lower() if not alias.case_sensitive else alias.command
148+
fcmd = forwardslash_alias_command.lower() if not alias.case_sensitive else forwardslash_alias_command
127149
if alias.case_sensitive:
128150
CASE_SENSITIVE_COMMANDS.add(alias.command)
151+
CASE_SENSITIVE_COMMANDS.add(forwardslash_alias_command)
129152
else:
130153
CASE_INSENSITIVE_COMMANDS.add(alias.command.lower())
154+
CASE_INSENSITIVE_COMMANDS.add(forwardslash_alias_command.lower())
131155
COMMANDS[cmd] = SpecialCommand(
132156
handler,
133157
command,
@@ -138,6 +162,16 @@ def register_special_command(
138162
hidden=True,
139163
aliases=None,
140164
)
165+
COMMANDS[fcmd] = SpecialCommand(
166+
handler,
167+
command,
168+
usage,
169+
description,
170+
arg_type=arg_type,
171+
case_sensitive=alias.case_sensitive,
172+
hidden=True,
173+
aliases=None,
174+
)
141175

142176

143177
def execute(cur: Cursor, sql: str) -> list[SQLResult]:
@@ -158,7 +192,7 @@ def execute(cur: Cursor, sql: str) -> list[SQLResult]:
158192

159193
# "help <SQL KEYWORD> is a special case. We want built-in help, not
160194
# mycli help here.
161-
if command.lower() == "help" and arg:
195+
if command.lower().startswith(("help", "/help", "\\?", "/?", "?")) and arg:
162196
return show_keyword_help(cur=cur, arg=arg)
163197

164198
if special_cmd.arg_type == ArgType.NO_QUERY:

mycli/packages/sql_utils.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,20 @@ def need_completion_refresh(queries: str) -> bool:
431431
for query in sqlparse.split(queries):
432432
try:
433433
first_token = query.split()[0]
434-
if first_token.lower() in ("alter", "create", "use", "\\r", "\\u", "connect", "drop", "rename"):
434+
if first_token.lower() in (
435+
"alter",
436+
"create",
437+
"use",
438+
"/use",
439+
"\\r",
440+
"\\u",
441+
"/r",
442+
"/u",
443+
"connect",
444+
"/connect",
445+
"drop",
446+
"rename",
447+
):
435448
return True
436449
except Exception:
437450
continue
@@ -447,9 +460,9 @@ def need_completion_reset(queries: str) -> bool:
447460
try:
448461
tokens = query.split()
449462
first_token = tokens[0]
450-
if first_token.lower() in ("use", "\\u"):
463+
if first_token.lower() in ("use", "/use", "\\u", "/u"):
451464
return True
452-
if first_token.lower() in ("\\r", "connect") and len(tokens) > 1:
465+
if first_token.lower() in ("\\r", "/r", "connect", "/connect") and len(tokens) > 1:
453466
return True
454467
except Exception:
455468
continue
@@ -502,7 +515,7 @@ def classify_sandbox_statement(text: str) -> tuple[str | None, str | None]:
502515
return ('quit', None)
503516

504517
# \q
505-
if len(tokens) == 2 and types[0] == tt.BACKSLASH and texts[1] == 'Q':
518+
if len(tokens) == 2 and types[0] in (tt.BACKSLASH, tt.SLASH) and texts[1] in ('Q', 'QUIT', 'EXIT'):
506519
return ('quit', None)
507520

508521
# ALTER USER ...

mycli/sqlexecute.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def run(self, statement: str) -> Generator[SQLResult, None, None]:
411411
# Split the sql into separate queries and run each one.
412412
# Unless it's saving a favorite query, in which case we
413413
# want to save them all together.
414-
if statement.startswith("\\fs"):
414+
if statement.startswith(("\\fs", "/fs")):
415415
components: Iterable[str] = [statement]
416416
else:
417417
components = iocommands.split_queries(statement)

test/pytests/test_clibuffer.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ def test_multiline_exception_detects_commands_terminators_and_plain_sql(
6161
expected: bool,
6262
) -> None:
6363
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: '//')
64-
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object(), 'exit': object()})
64+
monkeypatch.setattr(clibuffer, 'CASE_SENSITIVE_COMMANDS', {'Camel'})
65+
monkeypatch.setattr(clibuffer, 'CASE_INSENSITIVE_COMMANDS', {'help', 'exit'})
6566

6667
assert clibuffer._multiline_exception(text) is expected
6768

@@ -85,7 +86,8 @@ def test_multiline_exception_recognizes_non_backslashed_special_commands_with_ge
8586
text: str,
8687
) -> None:
8788
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: ';')
88-
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object(), 'exit': object()})
89+
monkeypatch.setattr(clibuffer, 'CASE_SENSITIVE_COMMANDS', {'Camel'})
90+
monkeypatch.setattr(clibuffer, 'CASE_INSENSITIVE_COMMANDS', {'help', 'exit'})
8991

9092
assert clibuffer._multiline_exception(text) is True
9193

@@ -107,7 +109,8 @@ def test_cli_is_multiline_uses_buffer_text_when_multiline_mode_is_enabled(
107109

108110
monkeypatch.setattr(clibuffer, 'get_app', lambda: app)
109111
monkeypatch.setattr(clibuffer.iocommands, 'get_current_delimiter', lambda: ';')
110-
monkeypatch.setattr(clibuffer, 'SPECIAL_COMMANDS', {'help': object()})
112+
monkeypatch.setattr(clibuffer, 'CASE_SENSITIVE_COMMANDS', {'Camel'})
113+
monkeypatch.setattr(clibuffer, 'CASE_INSENSITIVE_COMMANDS', {'help'})
111114

112115
multiline_filter = clibuffer.cli_is_multiline(mycli)
113116

0 commit comments

Comments
 (0)