Skip to content

Commit a2b36b8

Browse files
Suggest command instead of chatting on typo'd subcommand
A misspelled subcommand like 'leap deamon status' fell through the pre-parse 'unknown first token -> chat prompt' path, silently spawning a daemon and asking the LLM. Add a conservative did-you-mean guard: a short, command-like invocation whose first word is a near-miss of a known command now prints a suggestion and exits 2 instead of routing to chat. Genuine free-text prompts (longer sentences, non-command words) still chat.
1 parent 8776a26 commit a2b36b8

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/leapflow/cli/cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,23 @@ def _daemon_enabled(args: argparse.Namespace) -> bool:
175175
return raw not in {"0", "false", "no", "off"}
176176

177177

178+
def _suggest_known_command(token: str, known_commands: set[str]) -> str | None:
179+
"""Return the closest known command when a token looks like a typo, else None.
180+
181+
Uses a conservative similarity cutoff so genuine free-text prompts (e.g.
182+
``leap what is a daemon``) still route to chat, while near-miss command
183+
typos (e.g. ``deamon`` -> ``daemon``) are caught and surfaced as a
184+
suggestion instead of silently becoming an LLM chat turn that also spawns a
185+
daemon.
186+
"""
187+
import difflib
188+
189+
matches = difflib.get_close_matches(
190+
token.lower(), sorted(known_commands), n=1, cutoff=0.82
191+
)
192+
return matches[0] if matches else None
193+
194+
178195
def main(argv: list[str] | None = None) -> int:
179196
common = argparse.ArgumentParser(add_help=False)
180197
common.add_argument(
@@ -336,6 +353,21 @@ def main(argv: list[str] | None = None) -> int:
336353
break
337354

338355
if first_pos is not None and effective_argv[first_pos] not in known_commands:
356+
first_token = effective_argv[first_pos]
357+
non_flag_tokens = [tok for tok in effective_argv if not tok.startswith("-")]
358+
# A short, command-like invocation whose first word is a near-miss of a
359+
# known command is almost certainly a typo (e.g. `leap deamon status`).
360+
# Surface a suggestion instead of silently spawning a daemon + LLM chat.
361+
if len(non_flag_tokens) <= 3:
362+
suggestion = _suggest_known_command(first_token, known_commands)
363+
if suggestion is not None:
364+
corrected = " ".join(["leap", suggestion, *effective_argv[first_pos + 1:]])
365+
sys.stderr.write(
366+
f"leap: '{first_token}' is not a leap command. "
367+
f"Did you mean '{suggestion}'?\n"
368+
f"Try: {corrected}\n"
369+
)
370+
return 2
339371
# Collect all non-option prompt tokens while preserving global option values.
340372
flags: list[str] = []
341373
prompt_words = []

tests/test_cli_entrypoint.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,44 @@ async def fake_daemon_main(args):
11621162
assert captured == {"command": "chat", "prompt": "hello world"}
11631163

11641164

1165+
def test_leap_typo_command_suggests_instead_of_chatting(monkeypatch, capsys) -> None:
1166+
from leapflow.cli import cli
1167+
1168+
called = {"chat": False}
1169+
1170+
async def fake_daemon_main(args): # pragma: no cover - must not run
1171+
called["chat"] = True
1172+
return 0
1173+
1174+
monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main)
1175+
1176+
code = cli.main(["deamon", "status"])
1177+
1178+
assert code == 2 # usage error, not a chat turn
1179+
assert called["chat"] is False # never spawned a daemon or asked the LLM
1180+
err = capsys.readouterr().err
1181+
assert "Did you mean 'daemon'" in err
1182+
assert "leap daemon status" in err
1183+
1184+
1185+
def test_leap_long_freetext_near_miss_still_chats(monkeypatch) -> None:
1186+
from leapflow.cli import cli
1187+
1188+
captured = {}
1189+
1190+
async def fake_daemon_main(args):
1191+
captured["command"] = args.command
1192+
captured["prompt"] = args.prompt
1193+
return 0
1194+
1195+
monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main)
1196+
1197+
# First word is a near-miss of `daemon`, but a full sentence is genuine chat
1198+
# and must not be hijacked by the did-you-mean guard.
1199+
assert cli.main(["deamon", "is", "a", "background", "process"]) == 0
1200+
assert captured == {"command": "chat", "prompt": "deamon is a background process"}
1201+
1202+
11651203
@pytest.mark.asyncio
11661204
async def test_teach_start_without_session_returns_structured_error() -> None:
11671205
from types import SimpleNamespace

0 commit comments

Comments
 (0)