Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions agentdescent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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 ``--``).
Expand Down Expand Up @@ -4701,7 +4752,8 @@ def build_parser() -> argparse.ArgumentParser:
# agentdescent.cli run --run-dir <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")
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/886-unknown-verb-hint.md
Original file line number Diff line number Diff line change
@@ -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).
41 changes: 41 additions & 0 deletions tests/test_cli_help_hides_internal_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading