Accepting flag-style subcommands in the enforcer CLI

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

Background — how the CLI decides what to run

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.

sys.argv[1]e.g. abandon-pick
→
in _ALL_COMMANDS?membership test
→
_enforce_total_block_lockonly status, enforce survive a total block
→
_enforce_manual_pick_lockexempt set includes abandon-pick
→
handlercmd_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.

Intuition — one token, three possible fates

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.

Before

$ ./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

After

$ ./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 = "--abandon-pick"as typed
→
1. exact hit?raw in _ALL_COMMANDS → no
→
2. strip dashes"abandon-pick" → hit ✓
→
3. else: suggestskipped here

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.

Play with it

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.

    Press Step ▸ to begin.

    Code — what actually changed

    Two source concerns and one test concern. Roughly 55 lines of production code and 75 of tests; no behaviour outside argument resolution moved.

    1. Normalising the token

    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

    2. A usage printer that can explain itself

    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.

    3. Splitting the branch in 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.

    4. Tests

    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.

    Quiz

    Score: 0 / 0 answered