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 @@ +
+ 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
+ + + + + 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 @@3 explanations · interactive walkthroughs of notable changes, newest first.
+4 explanations · interactive walkthroughs of notable changes, newest first.