Why ./run.sh --abandon-pick 1794680 silently printed a help
screen, and what changed so it now does the thing you asked for.
commit a5dd569
The Steam Backlog Enforcer is a self-imposed discipline tool: it picks one game from your Steam library, uninstalls the rest, blocks the store, and refuses to let you switch until you finish the assigned game. Because the whole point is that you cannot talk yourself out of it, the CLI is deliberately narrow — a handful of named subcommands, each of which has to pass through two lock layers before it runs.
Those subcommands live in two dictionaries in
steam_backlog_enforcer/main.py. Most are ordinary
(description, handler) pairs in COMMANDS. Four
more — add-exception, pick-manual,
abandon-pick, block-gaming — take extra arguments
and are dispatched by hand, so they only carry a description in
_EXTRA_COMMAND_DESCRIPTIONS. The union of both is
_ALL_COMMANDS, and that union is the single source of truth for
"is this a real command?" as well as for the help screen.
Before the change, main() was a straight line: take
sys.argv[1], check it is a key of _ALL_COMMANDS,
then hand that exact string to the lock checks and finally to a handler.
abandon-pickstatus, enforce survive a total blockabandon-pickcmd_abandon_pick
Both lock functions take the command name as a string and compare it
against a frozenset of exempt names —
_TOTAL_BLOCK_EXEMPT_COMMANDS is just
{"status", "enforce"}; the manual-pick set is wider but still
explicit. That detail matters later: whatever string reaches those functions
is what the locks reason about.
The wrinkle is that this CLI is not purely positional. Individual commands do
take flags: add-exception --reason "..." passes
sys.argv[2:] to its own parser. So a user who has typed a flag
in this tool before has every reason to reach for
--abandon-pick.
Here is the whole problem in one line. The old condition was a single
or:
if len(sys.argv) < _MIN_CLI_ARGS or sys.argv[1] not in _ALL_COMMANDS:
Two genuinely different situations — "you gave me nothing" and "you gave me
something I don't recognise" — collapsed into one branch that printed the
same help text and exited 1. The help screen never repeated back what you had
typed, so a typo and an empty invocation were indistinguishable. And because
"--abandon-pick" != "abandon-pick" as far as a dict lookup is
concerned, a perfectly reasonable spelling landed in the "unrecognised" bucket
with no hint about why.
$ ./run.sh --abandon-pick 1794680 Steam Backlog Enforcer Usage: python -m ... <command> Commands: scan Scan library & assign a game check Check assigned game completion ... $ echo $? 1
$ ./run.sh --abandon-pick 1794680 Note: treating '--abandon-pick' as 'abandon-pick'. Abandoning manual pick: Vampire Survivors This will: - Drop this pick from your allowed games - Uninstall Vampire Survivors Type YES to abandon Vampire Survivors:
The change splits that one branch into three outcomes for the first argument,
and the ordering is the whole idea: an exact name wins outright; otherwise a
leading-dash spelling is normalised (leading dashes carry no meaning in the
subcommand slot, so there is nothing to lose by stripping them); otherwise the
token is genuinely unknown and we say so, with a nearest-neighbour suggestion
from difflib.
raw in _ALL_COMMANDS → no"abandon-pick" → hit ✓
One invariant holds this together. The normalised, canonical name is what
flows onward into the lock checks and the dispatch table; the raw token is
only ever used for the two messages aimed at the human. A dashed spelling
therefore cannot be a loophole: --reset is checked by the locks
as reset, gets refused exactly as reset would, and
the enforcer's guarantees are untouched.
Type anything into the box below and step through the resolution the way
main() now does. The suggestion step runs a faithful JavaScript
port of difflib's Ratcliff–Obershelp similarity ratio with the
same cutoff=0.6, so the "did you mean" answers here match what
the real CLI prints.
Two source concerns and one test concern. Roughly 55 lines of production code and 75 of tests; no behaviour outside argument resolution moved.
A small pure function, easy to test in isolation and impossible to get
half-applied. Note the guard: dashes are only stripped when the raw token
actually starts with one, and the stripped result still has to be a real
command — so -- and --bogus both fall through to
None.
def _resolve_command(raw: str) -> str | None:
if raw in _ALL_COMMANDS:
return raw
if raw.startswith("-"):
stripped = raw.lstrip("-")
if stripped in _ALL_COMMANDS:
return stripped
return None
The old help block was inlined in main(), which is why the
no-args path and the bad-command path had to share it verbatim. Extracting it
lets the bad-command path prepend two lines the other path must not print.
difflib.get_close_matches compares against
_ALL_COMMANDS — iterating a dict yields its keys, so this
searches command names, not descriptions.
def _print_usage(unknown: str | None = None) -> None:
if unknown is not None:
_echo(f"Unknown command: {unknown}")
close = difflib.get_close_matches(
unknown.lstrip("-"), _ALL_COMMANDS, n=1, cutoff=0.6
)
if close:
_echo(f"Did you mean '{close[0]}'?")
_echo("")
_echo("Steam Backlog Enforcer\n")
_echo("Usage: python -m steam_backlog_enforcer.main <command> [args]\n")
...
Stripping dashes again before the similarity search is deliberate:
--zzzz should be compared as zzzz, otherwise the
leading punctuation drags every ratio down and a near-miss like
--uninstal would get no suggestion.
main()
The empty-argv case and the unknown-command case now exit through different
doors, and the comment records the invariant so a future edit does not quietly
start passing sys.argv[1] to the locks.
if len(sys.argv) < _MIN_CLI_ARGS:
_print_usage()
sys.exit(1)
# Locks below are always given the canonical name, never raw argv,
# so a dashed spelling can never be used to dodge one.
command = _resolve_command(sys.argv[1])
if command is None:
_print_usage(sys.argv[1])
sys.exit(1)
if command != sys.argv[1]:
_echo(f"Note: treating '{sys.argv[1]}' as '{command}'.")
Everything below this point in main() is unchanged — it already
used the local command variable rather than re-reading
sys.argv, which is why the invariant was cheap to establish.
Added to tests/test_main_part3.py, following the file's existing
patch.object(sys, "argv", ...) style: a table-driven test of
_resolve_command (exact, dashed, --, unknown), a
dispatch test asserting both that the handler ran and that the
Note: treating … line was echoed, a parametrised check that
bogus and --bogus both name the offending token,
and two tests pinning the suggestion's presence on a near-miss and its
absence on a far-off token. The package stayed at 100% coverage.
Score: 0 / 0 answered