Skip to content
Merged
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- CLI polish: `--help`/`--help-all` now win even after a value-taking flag,
the short `-h` pages gained the typical sampling/config/streaming flags,
tab completion offers flag values (choices, themes, profiles).

## [0.2.0] - 2026-08-02

### Added
Expand Down Expand Up @@ -71,7 +77,6 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
(thinking strip, tool-call re-serialization): decode-time snapshots
retain the reply up to the divergence point (`GMLX_APC_DECODE_CKPT`,
default 512 generated tokens; `0` off).

- `run`/`chat --thinking on|off|adaptive` and `--reasoning-effort LEVEL`:
first-class switches for thinking models. The chat template picks the
spelling: MiniMax's three-state `thinking_mode`
Expand Down
7 changes: 4 additions & 3 deletions gmlx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ def _build_parser(prog: str = "gmlx chat") -> argparse.ArgumentParser:
)
from .cli import add_condensed_help
add_condensed_help(ap, (
"gguf", "--assistant", "--system-prompt", "--reasoning",
"--thinking", "--mmproj", "--max-tokens", "--temp", "--resume",
"--theme", "--verbose",
"gguf", "--assistant", "--config", "--profile", "--system-prompt",
"--reasoning", "--thinking", "--mmproj", "--max-tokens", "--temp",
"--top-p", "--min-p", "--max-kv-size", "--stream-experts",
"--resume", "--theme", "--verbose",
))
ap.add_argument(
"gguf", nargs="?", default=None,
Expand Down
15 changes: 14 additions & 1 deletion gmlx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,8 +695,9 @@ def _build_parser(prog: str = "gmlx run") -> argparse.ArgumentParser:
)
add_condensed_help(ap, (
"gguf", "--prompt", "--prompt-file", "--max-tokens", "--temp",
"--top-p", "--min-p", "--stop", "--config", "--profile",
"--system-prompt", "--reasoning", "--thinking", "--mmproj",
"--image", "--stream-experts", "--verbose",
"--image", "--max-kv-size", "--stream-experts", "--verbose",
))
ap.add_argument("gguf", help="Path to the GGUF file (sharded ok).")
prompt_group = ap.add_mutually_exclusive_group()
Expand Down Expand Up @@ -2204,6 +2205,18 @@ def umbrella_main(argv: list[str] | None = None) -> int:
print(f"error: unknown command {verb!r}.\n", file=sys.stderr)
_print_umbrella_help(prog)
return 2
# Help always wins: argparse lets a value-taking flag swallow a following
# help token (`chat --thinking --help` dies with "expected one argument"
# instead of printing help), so hoist a bare help flag to the front. Safe:
# argparse rejects flag-like option values anyway, so a bare help token
# can never be a legitimate value. --help-all only where a verb defines it.
for tok in rest:
if tok == "--":
break
if tok in ("-h", "--help") or (
tok == "--help-all" and verb in ("run", "chat")):
rest = [tok]
break
if verb != "doctor": # doctor must run on a broken env to diagnose it
from .upstream_seams import check_upstream_versions
try:
Expand Down
31 changes: 31 additions & 0 deletions gmlx/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ def _capture_help(verb: str) -> str:
return buf.getvalue()


def _choice_values(metavar: str) -> list[str]:
"""The values of a ``{a,b,c}`` choices metavar, else ``[]``."""
m = re.fullmatch(r"\{([^{}]+)\}", metavar or "")
if not m:
return []
return [v.strip() for v in m.group(1).split(",") if v.strip()]


def _named_value_candidates(flag: str) -> list[str]:
"""Value candidates for flags whose metavar hides an enumerable set
(themes, sampling profiles)."""
try:
if flag == "--theme":
from .theme import list_themes

return [f"{t}\tcolor theme" for t in list_themes()]
if flag == "--profile":
from .profiles import builtin_intents

return [f"{i}\tbuilt-in intent" for i in sorted(builtin_intents())]
except Exception: # noqa: BLE001 - value candidates are best-effort
return []
return []


def _is_pathish(metavar: str) -> bool:
mv = (metavar or "").upper()
return any(k in mv for k in ("PATH", "FILE", "DIR"))
Expand Down Expand Up @@ -293,8 +318,14 @@ def _complete(argv: list[str]) -> list[str]:
if prev.startswith("-") and "=" not in prev:
opt = _option_for(verb, prev)
if opt is not None and opt[1]: # the previous flag wants a value
choices = _choice_values(opt[1])
if choices: # a {on,off,...} choices flag
return choices
if _is_pathish(opt[1]):
return ["::files"]
named = _named_value_candidates(opt[0])
if named:
return named
# An endpoint flag (--host/--port/--url/--base-url) completes from the
# servers currently running; any other value flag (a temperature, a
# token count) has nothing to enumerate.
Expand Down
10 changes: 8 additions & 2 deletions gmlx/decode_feeder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@ def _print_wedges(self) -> None:
def __del__(self):
try:
self.close()
except Exception:
except BaseException: # noqa: BLE001 - incl. ^C during interpreter exit
pass


Expand All @@ -1412,8 +1412,14 @@ def _register_exit_close(feeder) -> None:

def _close_at_exit():
f = wref()
if f is not None:
if f is None:
return
try:
f.close()
except KeyboardInterrupt:
# ^C while already exiting: skip the rest of the cleanup
# (munlock/stat printing) - the OS reclaims it all anyway.
pass

atexit.register(_close_at_exit)

Expand Down
7 changes: 6 additions & 1 deletion gmlx/pagecache.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ def _exit_sweep() -> None:
for group in _groups:
paths.extend(p for p in group if p not in paths)
del _groups[:]
release_file_cache(paths, log=print if _log_release else None)
try:
release_file_cache(paths, log=print if _log_release else None)
except KeyboardInterrupt:
# ^C while already exiting: abandon the sweep quietly - the pages
# just stay cached, which only costs the next cold load.
pass


def register_streaming_release(paths) -> None:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_cli_umbrella.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,20 @@ def test_chat_help_all_is_complete(capsys):
chat.cmd_chat(["--help-all"])
out = capsys.readouterr().out
assert "--logit-bias" in out and "--kv-bits" in out


# Help always wins: a value-taking flag must not swallow a following help
# token (`chat --thinking --help` used to die with "expected one argument").
def test_help_after_value_flag_is_hoisted(routes):
assert cli.umbrella_main(["chat", "--thinking", "--help"]) == 0
assert routes["chat"] == ["--help"]
assert cli.umbrella_main(["run", "--profile", "--help-all"]) == 0
assert routes["run"] == ["--help-all"]
assert cli.umbrella_main(["serve", "model.gguf", "--config", "-h"]) == 0
assert routes["server"] == ["-h"]


def test_help_hoist_stops_at_double_dash(routes):
# Past a `--` separator nothing is a flag; the argv passes through intact.
assert cli.umbrella_main(["rm", "old-model", "--", "--help"]) == 0
assert routes["rm"] == ["old-model", "--", "--help"]
17 changes: 17 additions & 0 deletions tests/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ def test_non_path_flag_value_offers_nothing():
assert completion._complete(["run", "--temp", ""]) == []


def test_choices_flag_value_completes_choices():
assert completion._complete(["chat", "--thinking", ""]) == \
["on", "off", "adaptive"]
assert completion._complete(["run", "--reasoning", ""]) == \
["show", "hide", "raw"]


def test_theme_flag_value_completes_themes():
vals = _vals(completion._complete(["chat", "--theme", ""]))
assert "dark" in vals and "light" in vals


def test_profile_flag_value_completes_intents():
vals = _vals(completion._complete(["chat", "--profile", ""]))
assert "coding" in vals and "reasoning-high" in vals


def _write_cfg(tmp_path):
cfg = tmp_path / "models.yaml"
cfg.write_text(textwrap.dedent("""
Expand Down