From d50b5f248be3790c86477422729cae08242769f8 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 18:07:19 +0800 Subject: [PATCH] fix(cli): hide internal run verb and suggest near-miss commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #886 An unknown top-level verb no longer lists the detached-worker `run` command among argparse's choose-from choices (#420 hid it from help and usage only). A hyphen/case fold or close typo (algoritms, compile_skill) gets `did you mean …?`. --- agentdescent/cli.py | 56 ++++++++++++++++++++++- changelog.d/886-unknown-verb-hint.md | 4 ++ tests/test_cli_help_hides_internal_run.py | 41 +++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 changelog.d/886-unknown-verb-hint.md diff --git a/agentdescent/cli.py b/agentdescent/cli.py index b8687826..1e0339e9 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -4578,10 +4578,53 @@ def cmd_mcp(a: argparse.Namespace) -> int: # Sub-commands that exist for AgentDescent's own use and are kept out of -# `--help` and the usage line. +# `--help`, the usage line, and unknown-verb ``choose from`` lists (#420, #886). _INTERNAL_COMMANDS = frozenset({"run"}) +def _visible_choices(action: argparse.Action) -> List[Any]: + """``action.choices`` without internal commands, in registration order.""" + if action.choices is None: + return [] + return [c for c in action.choices if c not in _INTERNAL_COMMANDS] + + +def _did_you_mean_choice(value: Any, choices: Sequence[Any]) -> Optional[str]: + """The unique visible choice ``value`` most likely meant, or ``None``. + + An exact match after folding case and ``-``/``_`` wins (``compile_skill`` + → ``compile-skill``). Otherwise a single close typo at cutoff 0.75, the + same bar as unknown algorithm names (#874). + """ + if not isinstance(value, str) or not choices: + return None + folded = value.strip().casefold().replace("_", "-") + pool: Dict[str, Any] = {} + for choice in choices: + key = str(choice).casefold().replace("_", "-") + # Two visible names that fold together are ambiguous; skip both. + if key in pool: + pool[key] = None + else: + pool[key] = choice + if folded in pool and pool[folded] is not None: + return str(pool[folded]) + close = difflib.get_close_matches( + folded, [k for k, v in pool.items() if v is not None], n=1, cutoff=0.75) + return str(pool[close[0]]) if close else None + + +def _invalid_choice_message(action: argparse.Action, value: Any) -> str: + """argparse's invalid-choice text, minus internal verbs, plus a hint (#886).""" + visible = _visible_choices(action) + shown = ", ".join(repr(c) for c in visible) + msg = f"invalid choice: {value!r} (choose from {shown})" + guess = _did_you_mean_choice(value, visible) + if guess is not None: + msg += f"; did you mean {guess!r}?" + return msg + + class _CliParser(argparse.ArgumentParser): """ArgumentParser whose usage errors honour ``--json`` (#664). @@ -4590,6 +4633,9 @@ class _CliParser(argparse.ArgumentParser): ``--json`` that leaves a script with nothing to parse on stdout, so the same exit code instead comes with one ``{"ok": false, "error", "usage"}`` object. Subparsers inherit this class through ``add_subparsers``. + + ``_check_value`` hides internal verbs such as ``run`` from the + ``choose from`` list and appends ``did you mean`` for a near-miss (#886). """ json_errors = False @@ -4601,6 +4647,11 @@ def error(self, message: str): # type: ignore[override] self.exit(2) super().error(message) + def _check_value(self, action, value): # type: ignore[override] + if action.choices is None or value in action.choices: + return + raise argparse.ArgumentError(action, _invalid_choice_message(action, value)) + def _wants_json(argv: Sequence[str]) -> bool: """True when ``--json`` appears as an option (before any ``--``). @@ -4701,7 +4752,8 @@ def build_parser() -> argparse.ArgumentParser: # agentdescent.cli run --run-dir `); not a user verb. argparse does # not honour help=SUPPRESS on a sub-parser (it prints "==SUPPRESS==" and # keeps the name in the {...} list), so register it with no help at all - # -- no help line -- and leave it out of the metavar below (#420). + # -- no help line -- leave it out of the metavar below (#420), and strip + # it from unknown-verb ``choose from`` lists (#886). s = sub.add_parser("run", description="internal: the worker `agentdescent evolve " "--detach` starts; use `evolve` instead") s.add_argument("--run-dir", required=True, dest="run_dir") diff --git a/changelog.d/886-unknown-verb-hint.md b/changelog.d/886-unknown-verb-hint.md new file mode 100644 index 00000000..7d458488 --- /dev/null +++ b/changelog.d/886-unknown-verb-hint.md @@ -0,0 +1,4 @@ +Fixed +An unknown CLI verb no longer lists the internal `run` worker among +`choose from` choices, and a near-miss (`algoritms`, `compile_skill`) +gets `did you mean …?` (#886). diff --git a/tests/test_cli_help_hides_internal_run.py b/tests/test_cli_help_hides_internal_run.py index 378d7a67..446955c1 100644 --- a/tests/test_cli_help_hides_internal_run.py +++ b/tests/test_cli_help_hides_internal_run.py @@ -90,3 +90,44 @@ def test_runstore_still_spawns_the_run_verb(): # The detached worker's command line is what keeps `run` alive. src = open(runstore.__file__, encoding="utf-8").read() assert '"agentdescent.cli", "run", "--run-dir"' in src + +def _unknown_verb(capsys, argv): + with pytest.raises(SystemExit) as exc: + cli.main(argv) + assert exc.value.code == 2 + return capsys.readouterr() + + +def test_unknown_verb_error_does_not_list_run(capsys): + """A typo must not advertise the internal ``run`` worker (#886).""" + out, err = _unknown_verb(capsys, ["algoritms"]) + assert out == "" + assert "'run'" not in err + assert "invalid choice: 'algoritms'" in err + assert "did you mean 'algorithms'?" in err + + +@pytest.mark.parametrize("verb", ["compile_skill", "COMPILE-SKILL"]) +def test_unknown_verb_suggests_hyphen_or_case_fold(capsys, verb): + _, err = _unknown_verb(capsys, [verb]) + assert "did you mean 'compile-skill'?" in err + assert "'run'" not in err + + +def test_unknown_verb_json_hides_run_and_suggests(capsys): + import json + + out, err = _unknown_verb(capsys, ["--json", "algoritms"]) + assert err == "" + payload = json.loads(out) + assert payload["ok"] is False + assert "did you mean 'algorithms'?" in payload["error"] + assert "'run'" not in payload["error"] + assert "'run'" not in payload["usage"] + + +def test_nonsense_verb_has_no_guess_and_hides_run(capsys): + _, err = _unknown_verb(capsys, ["no-such-verb"]) + assert "invalid choice: 'no-such-verb'" in err + assert "did you mean" not in err + assert "'run'" not in err