From fbe66d86c7668a3844d56b79d9ef0c21d5f246ef Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 16:09:22 +0800 Subject: [PATCH] fix(cli): fold hyphen/case --agent names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #884 ## What changed `--agent` now behaves like algorithm names, parameter names, and `--kind`: a value that matches exactly one built-in worker short ref once case and `-`/`_` are ignored resolves to it (`claude-code` → `claude_code`, `openai-compatible` → `openai_compatible`). Written specs use the canonical `agent.ref`. Kind mismatch checks still run on the folded name (`--agent claude-code` on kind=text is still refused as a WorkspaceAgent). Install-only aliases that are not a spelling fold (`deepseek-harness`, `claudecode`, `open-code`) still hint; `grok-bot` still explains it has no worker CLI. ## Verification at the tip Ran: - pytest -q tests/test_init_honor_agent.py tests/test_changelog_fragments.py tests/test_init_default_agent.py tests/test_workspace_agent_hint.py tests/test_cli_json_usage_errors.py tests/test_cli_algorithms_run.py tests/test_install_host_aliases.py → passed - live `init --agent claude-code` / `--agent openai-compatible` write the canonical refs; `grok-bot` and `kind=text --agent claude-code` still refuse Not run: - full suite — CI covers 3.9–3.13 --- agentdescent/cli.py | 21 +++++++++- changelog.d/884-agent-aliases.md | 4 ++ tests/test_init_honor_agent.py | 66 +++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 changelog.d/884-agent-aliases.md diff --git a/agentdescent/cli.py b/agentdescent/cli.py index eb13025c..b8687826 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -892,13 +892,31 @@ def _starter_model_warnings(spec: Dict[str, Any], out: str) -> List[str]: _PLAIN_REFS = ("openai_compatible", "host_model", "claude", "echo") +def fold_agent_ref(ref: str) -> str: + """Canonical built-in agent short ref when ``ref`` is a case/hyphen alias. + + ``claude-code`` and ``CLAUDE_CODE`` become ``claude_code``. A value that + is not such an alias — including ``module:attribute`` and install-only + host names like ``deepseek-harness`` — is returned unchanged, so + :func:`init_agent_unknown` can still hint or refuse it. + """ + if not isinstance(ref, str) or ":" in ref: + return ref + folded = ref.strip().casefold().replace("-", "_") + if folded in _WORKSPACE_REFS + _PLAIN_REFS: + return folded + return ref + + def init_agent_unknown(ref: str) -> Optional[str]: """Why ``--agent ref`` can never resolve, or None (#745). ``plan`` would refuse a name that is neither a built-in agent short ref nor ``module:attribute``; say so before a spec is written, list only the agent refs (not the policy classes ``SHORT_REFS`` also holds), and point a host - name such as ``claude-code`` at its ref spelling. + name such as ``deepseek-harness`` at its worker spelling. Callers should + run :func:`fold_agent_ref` first so ``claude-code`` is stored as + ``claude_code`` rather than rejected (#884). """ agents = _WORKSPACE_REFS + _PLAIN_REFS if ref in agents or ":" in ref: @@ -1113,6 +1131,7 @@ def write_starter_spec( agent, found = default_agent_ref() else: found = True + agent = fold_agent_ref(agent) # Refuse a missing PATH before kind guess / agent mismatch / writing (#706). missing = require_existing_target(path, kind) if missing: diff --git a/changelog.d/884-agent-aliases.md b/changelog.d/884-agent-aliases.md new file mode 100644 index 00000000..2150823e --- /dev/null +++ b/changelog.d/884-agent-aliases.md @@ -0,0 +1,4 @@ +Fixed +`--agent` now folds case and `-`/`_` (`claude-code` → `claude_code`, +`openai-compatible` → `openai_compatible`), matching how algorithm names, +parameter names, and `--kind` already resolve (#884). diff --git a/tests/test_init_honor_agent.py b/tests/test_init_honor_agent.py index c608752b..95c95b1d 100644 --- a/tests/test_init_honor_agent.py +++ b/tests/test_init_honor_agent.py @@ -74,11 +74,43 @@ def test_policy_slot_keeps_explicit_agent(ws): assert json.loads((ws / "spec.json").read_text())["agent"] == {"ref": "host_model"} -@pytest.mark.parametrize("ref,hint", [("claude-code", "claude_code"), - ("grok-bot", None), ("bogus", None)]) -def test_unknown_agent_refused_before_writing(ws, capsys, ref, hint): - # A name `plan` can never resolve (here: install's host spelling) used to be - # written anyway, with an error listing policy classes as agents (#745). +@pytest.mark.parametrize("raw,canonical,target,kind", [ + ("claude-code", "claude_code", "my-skill", []), + ("CLAUDE_CODE", "claude_code", "my-skill", []), + ("cli-agent", "cli_agent", "my-skill", []), + ("openai-compatible", "openai_compatible", "prompt.txt", ["--kind", "text"]), + ("Host-Model", "host_model", "prompt.txt", ["--kind", "text"]), +]) +def test_agent_hyphen_case_aliases_fold(ws, raw, canonical, target, kind): + # Same case / "-" / "_" fold as --kind and algorithm params (#884). + path = "prompt.txt" if target == "prompt.txt" else str(ws / target) + assert _init(path, *kind, "--agent", raw) == 0 + assert json.loads((ws / "spec.json").read_text())["agent"]["ref"] == canonical + + +def test_algorithms_run_folds_host_spelling(ws): + rc = cli.main(["algorithms", "run", "archive_selection", str(ws / "my-skill"), + "--agent", "claude-code", "--out", "spec.json"]) + # Plan may still fail later (no cases / placeholder model). The alias must + # no longer be refused before write; the spec stores the canonical ref. + assert (ws / "spec.json").exists() + assert json.loads((ws / "spec.json").read_text())["agent"]["ref"] == "claude_code" + assert rc in (0, 2) + + +def test_text_refuses_folded_workspace_host_spelling(ws, capsys): + # Folding must not bypass the kind mismatch gate (#456). + assert _init("prompt.txt", "--kind", "text", "--agent", "claude-code") == 2 + assert not (ws / "spec.json").exists() + err = capsys.readouterr().err + assert "kind='text'" in err and "claude_code" in err + + +@pytest.mark.parametrize("ref", ["grok-bot", "bogus"]) +def test_unknown_agent_refused_before_writing(ws, capsys, ref): + # A name `plan` can never resolve used to be written anyway, with an + # error listing policy classes as agents (#745). Hyphen/case aliases of + # a real worker now fold (#884); leftover unknowns still refuse first. (ws / "spec.json").write_text('{"keep": 1}') assert _init(str(ws / "my-skill"), "--agent", ref) == 2 assert json.loads((ws / "spec.json").read_text()) == {"keep": 1} @@ -87,15 +119,7 @@ def test_unknown_agent_refused_before_writing(ws, capsys, ref, hint): assert f"--agent {ref}: not a known agent" in err assert "claude_code" in err and "opencode" in err assert "Beam" not in err and "DefaultFusion" not in err - assert ("did you mean `--agent claude_code`" in err) == (hint is not None) - - -def test_algorithms_run_refuses_unknown_agent(ws, capsys): - rc = cli.main(["algorithms", "run", "archive_selection", str(ws / "my-skill"), - "--agent", "claude-code", "--out", "spec.json"]) - assert rc == 2 - assert not (ws / "spec.json").exists() - assert "did you mean `--agent claude_code`" in capsys.readouterr().err + assert "did you mean `--agent claude_code`" not in err @pytest.mark.parametrize("ref", ["claude_code", "dsh", "codex", "cli_agent", "echo"]) @@ -131,3 +155,17 @@ def test_plain_unknown_agent_gets_no_host_hint(ws, capsys): assert _init(str(ws / "my-skill"), "--agent", "bogus") == 2 err = capsys.readouterr().err assert "install host" not in err and "did you mean" not in err + + +@pytest.mark.parametrize("raw,canonical", [ + ("claude-code", "claude_code"), + ("CLAUDE_CODE", "claude_code"), + ("openai-compatible", "openai_compatible"), + ("cli-agent", "cli_agent"), + ("deepseek-harness", "deepseek-harness"), + ("mypkg.models:mine", "mypkg.models:mine"), + ("claude_code", "claude_code"), +]) +def test_fold_agent_ref(raw, canonical): + assert cli.fold_agent_ref(raw) == canonical +