diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f505eb04..e282f055c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,8 @@ history. ### Fixed +- Search matches names however their separators are written. `comfy models search --text` on Cloud now finds `minimax_h3_ref2va_….safetensors` for `MiniMax H3` and `wan2.2_t2v_….safetensors` for `wan 2.2`: every word must appear, with `- _ . space` ignored, the rule local search already used. Before, Cloud sent the whole query as one substring and a multi-word name found nothing. `comfy templates ls` (and `templates get --where`) match `--model`, `--name` and `--provider` the same way, so `--model "Wan 2.2"` finds the gallery's `Wan2.2`, and `comfy nodes search` finds `LTX 2.5` nodes for `LTX-2.5`. On Cloud a word the filename splits (`sdxl` for `sd_xl_base_1.0.safetensors`) is found too: when the server's narrowed answer has no match, search scans without the server filter. A scan stops after 10,000 assets, and `total_is_lower_bound` is then true. + - `comfy build push --dry-run` now runs the push's registry-pin check, so a pin the builder cannot place fails the dry run with the push's own `build_registry_pin_missing` error instead of passing it. A version the registry has Flagged was the common case: the dry run passed, and the push was refused only after the plan had been disclosed. The check asks the builder's snapshot importer to place each public pack, a read that writes nothing, and the dry run still uploads and saves nothing. The `--json` payload of a push and a dry run carries `public_pins_checked`, the number of public pins the builder placed (0 when the spec has none and the builder was not asked), so a script can tell a checked pin set from a spec with nothing to check. A spec that pins a public pack now needs sign-in for its dry run (`build_not_signed_in` otherwise); a spec whose packs are all local still sends no request. Push and dry run also refuse when the registry did not answer for a pack (the importer's `unverifiedPins`), instead of treating the unchecked pin as resolved; refuse with `build_spec_invalid`, before sending anything, a `repository` that is not a public GitHub address (`https://github.com//`, `git@github.com:/.git` or `ssh://git@github.com//`), so one that carries a user name or token, does not parse, or names another host never leaves the machine; and report a builder response that times out or drops mid-body as `build_builder_error` instead of an internal error. - Parallel build and deploy requests rejected on an old access token now adopt a peer's refreshed token under the shared refresh lock instead of rotating it again. Each rejected request retries at most once. If session recovery still fails while a refreshable sign-in remains stored, the watch reports that the remote work may continue instead of telling you to sign in again. `comfy build release create --watch`, `comfy deploy up`, `promote`, `rollback` and `comfy deploy status --watch` now end with the new code `watch_auth_lost` and exit code 75, as `deploy_watch_lost` does. Its hint names the command that reads the release or deployment again. A 401 before anything was accepted, on a workspace API key, or when no refreshable sign-in remains still reports `build_not_signed_in` or `deploy_not_signed_in`. diff --git a/comfy_cli/command/models/search.py b/comfy_cli/command/models/search.py index fcab06c23..522392538 100644 --- a/comfy_cli/command/models/search.py +++ b/comfy_cli/command/models/search.py @@ -424,35 +424,106 @@ def _cloud_search( type_: str | None, limit: int, include_public: bool, -) -> tuple[list[dict[str, Any]], int]: - """Page through /api/assets, returning (rows, total). +) -> tuple[list[dict[str, Any]], int, bool]: + """Page through /api/assets, returning ``(rows, total, total_is_lower_bound)``. The ``include_tags`` parameter is comma-separated, not repeated — cloud rejects the repeated-key form (``include_tags=a&include_tags=b``) per its OpenAPI spec (``style: form, explode: false``). + + ``name_contains`` is one case-insensitive substring over the whole name, so + a query the user writes with spaces ("MiniMax H3") can never match the + filename (``minimax_h3_…``). Search therefore follows the local rule + (``_name_matches``: every word, separators ignored) in up to two passes: + + 1. Ask the server for the query's longest fragment, the cheap narrowing + (for one plain word that is the whole query, sent unchanged), and check + every word client-side. + 2. If that finds nothing, the fragment may be split in the filename + (``sdxl`` vs ``sd_xl_base_1.0``), which the server cannot see. Scan + without ``name_contains`` and match client-side. + + A scan stops after ``_CLOUD_TOKEN_SCAN_MAX_PAGES`` pages; when it stops + with more assets left, ``total`` counts only what it saw and the third + value is True. """ tags = ["models"] if type_: tags.append(_TYPE_TO_FOLDER.get(type_, type_)) - params: dict[str, Any] = { + base: dict[str, Any] = { "include_tags": ",".join(tags), - "limit": min(max(limit, 1), 500), "include_public": str(include_public).lower(), } - if text: - params["name_contains"] = text + tokens = _search_tokens(text) + if tokens is None: + # No filter: one page, the server's own total. + body = _assets_page(target, {**base, "limit": min(max(limit, 1), 500)}) + rows = [_asset_to_row(a) for a in body.get("assets") or [] if isinstance(a, dict)] + return rows, int(body.get("total") or len(rows)), False + if not tokens: + # Only separators: a filter nothing satisfies (same as local). + return [], 0, False + + fragments = [f for f in _NAME_SEPARATORS.split(text.lower()) if f] + plain_word = len(fragments) == 1 and fragments[0] == text.lower().strip() + if plain_word: + # The server's substring match is already exact for a plain word: + # one page, and its total, when it finds anything. + body = _assets_page(target, {**base, "name_contains": text, "limit": min(max(limit, 1), 500)}) + rows = [_asset_to_row(a) for a in body.get("assets") or [] if isinstance(a, dict)] + if rows: + return rows, int(body.get("total") or len(rows)), False + else: + found = _scan_assets(target, {**base, "name_contains": max(fragments, key=len)}, tokens, limit) + if found[1]: + return found + return _scan_assets(target, base, tokens, limit) + + +def _scan_assets( + target, params: dict[str, Any], tokens: list[tuple[str, str]], limit: int +) -> tuple[list[dict[str, Any]], int, bool]: + """Page ``/api/assets`` with ``params``, keeping assets whose name or display name passes ``tokens``. + + Returns ``(rows, total, total_is_lower_bound)``: ``rows`` holds at most + ``limit`` matches, ``total`` counts every match seen, and the flag is True + when the page cap stopped the scan before the server ran out. + """ + rows: list[dict[str, Any]] = [] + total = 0 + offset = 0 + for _ in range(_CLOUD_TOKEN_SCAN_MAX_PAGES): + body = _assets_page(target, {**params, "limit": _CLOUD_TOKEN_SCAN_PAGE, "offset": offset}) + page = [a for a in body.get("assets") or [] if isinstance(a, dict)] + for asset in page: + names = (asset.get("name") or "", asset.get("display_name") or "") + if any(isinstance(n, str) and n and _name_matches(n, tokens) for n in names): + total += 1 + if len(rows) < max(limit, 0): + rows.append(_asset_to_row(asset)) + offset += len(page) + if not page or not body.get("has_more"): + return rows, total, False + return rows, total, True + + +# A scan pages until the server runs out, because the server's filter (one +# fragment, or none) returns rows the other words still reject. Capped so a +# broad query cannot page forever: 20 × 500 = 10k assets per scan. +_CLOUD_TOKEN_SCAN_PAGE = 500 +_CLOUD_TOKEN_SCAN_MAX_PAGES = 20 - qs = urllib.parse.urlencode(params) - url = target.url("assets") + "?" + qs + +def _assets_page(target, params: dict[str, Any]) -> dict[str, Any]: + """GET one page of ``/api/assets`` with ``params`` and return the JSON object.""" + url = target.url("assets") + "?" + urllib.parse.urlencode(params) body = _http_get_json(url, target) if not isinstance(body, dict): # Callers route JSONDecodeError to an envelope error; a non-object # top-level body (list/scalar) must surface the same way, not crash # on body.get(). raise json.JSONDecodeError(f"unexpected response shape (not an object) from {url}", "", 0) - assets = body.get("assets") or [] - rows = [_asset_to_row(a) for a in assets if isinstance(a, dict)] - return rows, int(body.get("total") or len(rows)) + return body def _local_folder_names(target) -> list[str]: @@ -670,9 +741,12 @@ def search_cmd( try: if target.is_cloud: - rows, total = _cloud_search(target, text=text, type_=type_, limit=limit, include_public=include_public) + rows, total, total_is_lower_bound = _cloud_search( + target, text=text, type_=type_, limit=limit, include_public=include_public + ) else: rows, total = _local_search(target, text=text, type_=type_, limit=limit) + total_is_lower_bound = False except urllib.error.HTTPError as e: _emit_http_error( e, @@ -693,6 +767,9 @@ def search_cmd( "mode": "cloud" if target.is_cloud else "local", "filters": {"text": text, "type": type_, "include_public": include_public if target.is_cloud else None}, "total": total, + # True when the cloud scan hit its page cap: `total` counts only the + # assets it reached, so more matches may exist. Narrow the query. + "total_is_lower_bound": total_is_lower_bound, "shown": len(rows), "rows": rows, } @@ -715,7 +792,11 @@ def search_cmd( sanitize_markup((r["source_url"] or "")[:48]), ) renderer.console().print(tbl) - tail = f" (of {total} total)" if total != len(rows) else "" + tail = ( + f" (of {total}{'+' if total_is_lower_bound else ''} total)" + if total != len(rows) or total_is_lower_bound + else "" + ) rprint(f"[dim]{len(rows)} model(s){tail} ({payload['mode']})[/dim]") knowledge.attach( payload, diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index e74b654d5..5f92a8a56 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -21,6 +21,7 @@ import difflib import json +import re import shlex from typing import Annotated, Any @@ -559,6 +560,28 @@ def show_cmd( # --------------------------------------------------------------------------- +# Separators inside a word: 'LTX-2.5', 'ltx_2.5' and 'LTX 2.5' name one model. +_WORD_SEPARATORS = re.compile(r"[-_. ]+") + + +def _all_tokens_in(tokens: list[str], field: str) -> bool: + """Every token is in ``field``, as written or with ``- _ . space`` removed + from both sides. A token made only of separators keeps its raw meaning, so + ``-`` stays a literal rather than squashing to a match-everything ``""``.""" + squashed_field = None + for t in tokens: + if t in field: + continue + st = _WORD_SEPARATORS.sub("", t) + if not st: + return False + if squashed_field is None: + squashed_field = _WORD_SEPARATORS.sub("", field) + if st not in squashed_field: + return False + return True + + @app.command( "search", help=( @@ -650,13 +673,13 @@ def search_cmd( score = 0 elif name_l.startswith(q) or name_l.startswith(q_joined): score = 1 - elif all(t in name_l for t in tokens): + elif _all_tokens_in(tokens, name_l): score = 2 - elif all(t in display_l for t in tokens): + elif _all_tokens_in(tokens, display_l): score = 3 - elif all(t in cat_l for t in tokens): + elif _all_tokens_in(tokens, cat_l): score = 4 - elif all(t in blob for t in tokens): + elif _all_tokens_in(tokens, blob): score = 5 else: continue diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index da02e6f9b..9e6227c56 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -19,6 +19,7 @@ import json import os +import re import subprocess import sys import time @@ -489,15 +490,34 @@ def _matches( return False if tag and not any((t or "").lower() == tag.lower() for t in row.get("tags") or []): return False - if model and not any(model.lower() in (m or "").lower() for m in row.get("models") or []): + if model and not any(_contains(m, model) for m in row.get("models") or []): return False - if provider and not any(provider.lower() in (p or "").lower() for p in row.get("providers") or []): + if provider and not any(_contains(p, provider) for p in row.get("providers") or []): return False - if name_sub and name_sub.lower() not in (row.get("name") or "").lower(): + if name_sub and not _contains(row.get("name"), name_sub): return False return True +# The gallery and its readers put separators in different places: the gallery +# says "Wan2.2" and "Z-Image-Turbo", a request says "Wan 2.2" and "z image". +_SEPARATORS = re.compile(r"[-_. ]+") + + +def _contains(haystack: Any, needle: str) -> bool: + """Case-insensitive substring match that also ignores ``- _ . space``. + + The raw match is tried first so a needle made only of separators (which + squashes to nothing) still means itself rather than "match everything". + """ + hay = (haystack or "").lower() if isinstance(haystack, str) else "" + n = needle.lower() + if n in hay: + return True + squashed = _SEPARATORS.sub("", n) + return bool(squashed) and squashed in _SEPARATORS.sub("", hay) + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- diff --git a/comfy_cli/schemas/models.json b/comfy_cli/schemas/models.json index 1b308f5c8..a4499cf42 100644 --- a/comfy_cli/schemas/models.json +++ b/comfy_cli/schemas/models.json @@ -17,6 +17,7 @@ } }, "total": { "type": "integer", "description": "Total items before capping (list-folder, search)." }, + "total_is_lower_bound": { "type": "boolean", "description": "search: true when the Cloud scan stopped at its page cap, so `total` counts only the assets it reached and more matches may exist. Narrow the query. Always false on local." }, "shown": { "type": "integer", "description": "Number of items in the response after capping." }, "count": { "type": "integer", "description": "Count of folders (list-folders)." }, "folder": { "type": "string", "description": "Folder name that was listed (list-folder)." }, diff --git a/tests/comfy_cli/command/models/test_search.py b/tests/comfy_cli/command/models/test_search.py index 5503e450c..13a252360 100644 --- a/tests/comfy_cli/command/models/test_search.py +++ b/tests/comfy_cli/command/models/test_search.py @@ -880,6 +880,126 @@ def test_cloud_http_error_decodes_body(self, cloud_target, monkeypatch, capsys): assert env["error"]["details"]["body"] == '{"detail": "bad tag"}' +# Cloud asset names as the catalog actually spells them: separators sit wherever +# the uploader put them, and the display name a user types ("MiniMax H3") never +# appears verbatim in the filename. +_CLOUD_CATALOG = [ + "minimax_h3_ref2va_pruned_int8_convrot.safetensors", + "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "minimax_music3_dit_fp16.safetensors", + "sd_xl_base_1.0.safetensors", + "wan2.2_t2v_lightx2v.safetensors", + "flux1-dev.safetensors", +] + + +def _patch_asset_server(monkeypatch: pytest.MonkeyPatch, names: list[str]): + """Serve /api/assets the way the real server filters it. + + ``name_contains`` is ONE case-insensitive substring over the whole name + (ingest's ``NameContainsFold``), paged by ``limit``/``offset`` with + ``has_more``. Returns the parsed query of every request made. + """ + queries: list[dict[str, list[str]]] = [] + + def _fake(req, timeout=None): + url = req.full_url if hasattr(req, "full_url") else str(req) + query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + queries.append(query) + needle = (query.get("name_contains") or [""])[0].lower() + limit = int((query.get("limit") or ["20"])[0]) + offset = int((query.get("offset") or ["0"])[0]) + hits = [n for n in names if needle in n.lower()] + page = hits[offset : offset + limit] + body = { + "assets": [{"name": n, "tags": ["models", "diffusion_models"]} for n in page], + "total": len(hits), + "has_more": offset + len(page) < len(hits), + } + return _fake_resp(json.dumps(body).encode()) + + import comfy_cli.http as http_mod + + monkeypatch.setattr(http_mod._AUTHED_OPENER, "open", _fake) + return queries + + +class TestCloudSearchTokens: + """Cloud `--text` matches the way local does: every word, separators ignored.""" + + def test_display_name_with_a_space_finds_the_underscored_file(self, cloud_target, monkeypatch, capsys): + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "MiniMax H3", "--where", "cloud"], capsys) + assert env["ok"] is True, env + assert sorted(r["name"] for r in env["data"]["rows"]) == [ + "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "minimax_h3_ref2va_pruned_int8_convrot.safetensors", + ] + assert env["data"]["total"] == 2 + + def test_version_written_with_a_space_matches_the_joined_spelling(self, cloud_target, monkeypatch, capsys): + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "wan 2.2", "--where", "cloud"], capsys) + assert [r["name"] for r in env["data"]["rows"]] == ["wan2.2_t2v_lightx2v.safetensors"] + + def test_every_word_must_match(self, cloud_target, monkeypatch, capsys): + """`minimax h3` must not return `minimax_music3` just because it shares the first word.""" + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "minimax h3", "--where", "cloud"], capsys) + assert "minimax_music3_dit_fp16.safetensors" not in [r["name"] for r in env["data"]["rows"]] + + def test_pages_past_rows_the_words_reject_to_fill_the_limit(self, cloud_target, monkeypatch, capsys): + """The server filters on one word, so its first page can be all rejects.""" + names = [f"minimax_music3_variant{i}.safetensors" for i in range(30)] + [ + "minimax_h3_ref2va_pruned_int8_convrot.safetensors" + ] + _patch_asset_server(monkeypatch, names) + env = _run(["search", "--text", "minimax h3", "--limit", "5", "--where", "cloud"], capsys) + assert [r["name"] for r in env["data"]["rows"]] == ["minimax_h3_ref2va_pruned_int8_convrot.safetensors"] + + def test_single_word_query_is_sent_to_the_server_unchanged(self, cloud_target, monkeypatch, capsys): + queries = _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "flux", "--where", "cloud"], capsys) + assert [r["name"] for r in env["data"]["rows"]] == ["flux1-dev.safetensors"] + assert queries[0]["name_contains"] == ["flux"] + assert len(queries) == 1 + + def test_a_word_the_filename_splits_still_finds_it(self, cloud_target, monkeypatch, capsys): + """`sdxl base` must find `sd_xl_base_1.0`, as local search does, though the server only knows `sd_xl`.""" + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "sdxl base", "--where", "cloud"], capsys) + assert [r["name"] for r in env["data"]["rows"]] == ["sd_xl_base_1.0.safetensors"] + assert env["data"]["total"] == 1 + + def test_a_single_word_the_filename_splits_still_finds_it(self, cloud_target, monkeypatch, capsys): + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "sdxl", "--where", "cloud"], capsys) + assert [r["name"] for r in env["data"]["rows"]] == ["sd_xl_base_1.0.safetensors"] + + def test_a_query_nothing_matches_is_still_empty(self, cloud_target, monkeypatch, capsys): + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "hunyuan 3d", "--where", "cloud"], capsys) + assert env["data"]["rows"] == [] + assert env["data"]["total"] == 0 + assert env["data"]["total_is_lower_bound"] is False + + def test_a_scan_stopped_by_the_page_cap_says_its_total_is_a_lower_bound(self, cloud_target, monkeypatch, capsys): + import comfy_cli.command.models.search as search_mod + + monkeypatch.setattr(search_mod, "_CLOUD_TOKEN_SCAN_PAGE", 2) + monkeypatch.setattr(search_mod, "_CLOUD_TOKEN_SCAN_MAX_PAGES", 2) + names = [f"minimax_h3_v{i}.safetensors" for i in range(10)] + _patch_asset_server(monkeypatch, names) + env = _run(["search", "--text", "minimax h3", "--where", "cloud"], capsys) + assert env["data"]["total"] == 4 + assert env["data"]["total_is_lower_bound"] is True + + def test_a_scan_that_reaches_the_end_has_an_exact_total(self, cloud_target, monkeypatch, capsys): + _patch_asset_server(monkeypatch, _CLOUD_CATALOG) + env = _run(["search", "--text", "MiniMax H3", "--where", "cloud"], capsys) + assert env["data"]["total_is_lower_bound"] is False + + # --------------------------------------------------------------------------- # show # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/command/test_nodes_introspect.py b/tests/comfy_cli/command/test_nodes_introspect.py index ff4e7d847..71655a695 100644 --- a/tests/comfy_cli/command/test_nodes_introspect.py +++ b/tests/comfy_cli/command/test_nodes_introspect.py @@ -306,6 +306,37 @@ def test_category_is_searched(self, patched_loader, capsys): assert "KSampler" in names assert "KSamplerAdvanced" in names + @pytest.mark.parametrize("query", ["LTX-2.5", "ltx_2.5", "ltx 2.5", "wan2.2 animate"]) + def test_query_ignores_separators(self, query, monkeypatch, capsys): + """Version names are written 'LTX-2.5' or 'wan2.2' as often as 'LTX 2.5'.""" + from comfy_cli.cql.engine import Graph + + info = _fake_object_info() + for cls, display in ( + ("LTXV25ImageToVideo", "LTX 2.5 Image to Video"), + ("WanAnimateToVideo", "Wan 2.2 Animate"), + ): + info[cls] = { + "input": {"required": {}}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "video", + "display_name": display, + "description": "", + "output_node": False, + "python_module": "nodes", + } + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: Graph.from_object_info(info)) + env = _run(["search", query], capsys) + names = [r["name"] for r in env["data"]["rows"]] + want = "WanAnimateToVideo" if "wan" in query else "LTXV25ImageToVideo" + assert names[:1] == [want], names + assert env["data"]["close_match"] is False, "a real match, not the typo fallback" + + def test_a_separator_only_query_stays_a_word_not_a_wildcard(self, patched_loader, capsys): + env = _run(["search", "-"], capsys) + assert env["data"]["total"] == 0 + def test_exact_name_outranks_prefix_sibling(self, patched_loader, capsys): """Regression: tier 0 (exact) must beat KSamplerAdvanced's tier 1.""" env = _run(["search", "KSampler"], capsys) diff --git a/tests/comfy_cli/command/test_templates.py b/tests/comfy_cli/command/test_templates.py index f318ffead..715ccca23 100644 --- a/tests/comfy_cli/command/test_templates.py +++ b/tests/comfy_cli/command/test_templates.py @@ -190,6 +190,55 @@ def test_ls_limit_applies_after_filter(gallery_file): assert env["data"]["shown"] == 1 +# The gallery spells model versions without a space ("Wan2.2") while users and +# agents write them with one ("Wan 2.2"). +_SEPARATOR_FIXTURE = [ + { + "title": "Video", + "type": "video", + "templates": [ + {"name": "video_wan2_2_14B_t2v", "title": "Wan 2.2 Text to Video", "tags": [], "models": ["Wan2.2"]}, + {"name": "video_wan2_1_vace", "title": "Wan 2.1 VACE", "tags": [], "models": ["Wan2.1 VACE"]}, + {"name": "image_z_image_turbo", "title": "Z Image Turbo", "tags": [], "models": ["Z-Image-Turbo"]}, + ], + } +] + + +def _ls_names(tmp_path: Path, *args: str) -> list[str]: + path = tmp_path / "index.json" + path.write_text(json.dumps(_SEPARATOR_FIXTURE)) + _force_json_renderer() + result = CliRunner().invoke(templates_cmd.app, ["ls", "--gallery", str(path), *args]) + assert result.exit_code == 0, result.output + return [r["name"] for r in _envelope(result.output)["data"]["rows"]] + + +@pytest.mark.parametrize("query", ["Wan 2.2", "wan2.2", "WAN-2.2", "wan_2_2"]) +def test_ls_model_filter_ignores_separators(tmp_path, query): + assert _ls_names(tmp_path, "--model", query) == ["video_wan2_2_14B_t2v"] + + +def test_ls_model_filter_still_matches_a_plain_prefix(tmp_path): + assert _ls_names(tmp_path, "--model", "Wan") == ["video_wan2_2_14B_t2v", "video_wan2_1_vace"] + + +def test_ls_name_filter_ignores_separators(tmp_path): + """`z image` finds `image_z_image_turbo`; the name uses underscores.""" + assert _ls_names(tmp_path, "--name", "z image turbo") == ["image_z_image_turbo"] + + +def test_get_where_model_ignores_separators(tmp_path, monkeypatch): + """`templates get` resolves through the same predicate, so it must agree with `ls`.""" + path = tmp_path / "index.json" + path.write_text(json.dumps(_SEPARATOR_FIXTURE)) + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", lambda name, timeout=15.0: b'{"nodes": []}') + _force_json_renderer() + result = CliRunner().invoke(templates_cmd.app, ["get", "--gallery", str(path), "--where", "model=Wan 2.2"]) + assert result.exit_code == 0, result.output + assert _envelope(result.output)["data"]["name"] == "video_wan2_2_14B_t2v" + + def test_show_returns_full_template(gallery_file): _force_json_renderer() runner = CliRunner()