diff --git a/docs/explanations/2026-07-19-explanation-cli-dashed-subcommands.html b/docs/explanations/2026-07-19-explanation-cli-dashed-subcommands.html new file mode 100644 index 0000000..b6d6662 --- /dev/null +++ b/docs/explanations/2026-07-19-explanation-cli-dashed-subcommands.html @@ -0,0 +1,672 @@ +Accepting flag-style subcommands in the enforcer CLI + + +

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

    +
    + + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index aa236a2..edaaafa 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,12 +30,16 @@
    steam-backlog-enforcer

    Change explanations

    -

    3 explanations · interactive walkthroughs of notable changes, newest first.

    +

    4 explanations · interactive walkthroughs of notable changes, newest first.

    2026-07-19 Manual picks: a way out, and a second slot + + 2026-07-19 + Accepting flag-style subcommands in the enforcer CLI + 2026-07-18 Fix: Steam CDP restart never worked