sv-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. sv_cli/__init__.py +3 -0
  2. sv_cli/adapters.py +323 -0
  3. sv_cli/api_client.py +82 -0
  4. sv_cli/commands/__init__.py +0 -0
  5. sv_cli/commands/auth.py +4 -0
  6. sv_cli/commands/better_keywords.py +4 -0
  7. sv_cli/commands/call.py +4 -0
  8. sv_cli/commands/config.py +4 -0
  9. sv_cli/commands/content_quality.py +5 -0
  10. sv_cli/commands/content_transformer.py +4 -0
  11. sv_cli/commands/core_analysis.py +4 -0
  12. sv_cli/commands/definitions.py +4 -0
  13. sv_cli/commands/geo_audit.py +4 -0
  14. sv_cli/commands/insight_igniter.py +4 -0
  15. sv_cli/commands/marketplace_services.py +5 -0
  16. sv_cli/commands/options.py +4 -0
  17. sv_cli/commands/preliminary_audit.py +4 -0
  18. sv_cli/commands/ranklens.py +4 -0
  19. sv_cli/commands/seo_image.py +4 -0
  20. sv_cli/commands/seo_mapping.py +4 -0
  21. sv_cli/commands/seogpt.py +4 -0
  22. sv_cli/commands/seogpt2.py +4 -0
  23. sv_cli/commands/seogpt_compare.py +4 -0
  24. sv_cli/commands/top_competitors.py +5 -0
  25. sv_cli/commands/topical_authority.py +4 -0
  26. sv_cli/config.py +216 -0
  27. sv_cli/definitions.py +283 -0
  28. sv_cli/errors.py +55 -0
  29. sv_cli/executor.py +188 -0
  30. sv_cli/formatter.py +227 -0
  31. sv_cli/main.py +905 -0
  32. sv_cli/renderers/__init__.py +0 -0
  33. sv_cli/renderers/csv_renderer.py +9 -0
  34. sv_cli/renderers/json_renderer.py +10 -0
  35. sv_cli/renderers/markdown_renderer.py +9 -0
  36. sv_cli/renderers/table_renderer.py +9 -0
  37. sv_cli/renderers/text_renderer.py +9 -0
  38. sv_cli/resolver.py +523 -0
  39. sv_cli/schemas/__init__.py +0 -0
  40. sv_cli/schemas/api_response.py +12 -0
  41. sv_cli/schemas/config.py +13 -0
  42. sv_cli/schemas/tool_definition.py +17 -0
  43. sv_cli/tasks.py +168 -0
  44. sv_cli/utils.py +204 -0
  45. sv_cli-0.3.0.dist-info/METADATA +338 -0
  46. sv_cli-0.3.0.dist-info/RECORD +49 -0
  47. sv_cli-0.3.0.dist-info/WHEEL +4 -0
  48. sv_cli-0.3.0.dist-info/entry_points.txt +2 -0
  49. sv_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sv_cli.formatter import to_csv
6
+
7
+
8
+ def render(data: Any) -> str:
9
+ return to_csv(data)
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from sv_cli.utils import mask_mapping
7
+
8
+
9
+ def render(data: Any) -> str:
10
+ return json.dumps(mask_mapping(data), indent=2, ensure_ascii=False)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sv_cli.formatter import to_markdown
6
+
7
+
8
+ def render(data: Any) -> str:
9
+ return to_markdown(data)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sv_cli.formatter import to_table_text
6
+
7
+
8
+ def render(data: Any) -> str:
9
+ return to_table_text(data)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sv_cli.formatter import to_text
6
+
7
+
8
+ def render(data: Any) -> str:
9
+ return to_text(data)
sv_cli/resolver.py ADDED
@@ -0,0 +1,523 @@
1
+ """Human-friendly enum and field resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import re
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from .adapters import field_candidates
12
+ from .errors import AmbiguousMatchError, InvalidInputError
13
+ from .utils import compact_text, normalize_text, slugify
14
+
15
+ OPTION_CONTAINER_KEYS = {
16
+ "options",
17
+ "values",
18
+ "enum",
19
+ "enums",
20
+ "choices",
21
+ "items",
22
+ "list",
23
+ "data",
24
+ }
25
+
26
+ ID_KEYS = ("id", "ID", "Id", "value", "Value", "key", "Key", "code", "Code", "enum", "enum_value")
27
+ LABEL_KEYS = (
28
+ "label",
29
+ "Label",
30
+ "name",
31
+ "Name",
32
+ "title",
33
+ "Title",
34
+ "text",
35
+ "Text",
36
+ "display",
37
+ "Display",
38
+ "description",
39
+ "Description",
40
+ )
41
+ SLUG_KEYS = ("slug", "Slug", "handle", "Handle")
42
+ ALIAS_KEYS = ("aliases", "alias", "synonyms", "shortcuts", "examples")
43
+ PARAM_NAME_KEYS = ("name", "field", "param", "parameter", "key")
44
+ VALID_VALUES_PATTERN = re.compile(r"valid values?\s*:\s*([^\n.]+(?:\.[^\n]*)?)", re.IGNORECASE)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Candidate:
49
+ field: str
50
+ id: Any
51
+ label: str
52
+ slug: str
53
+ aliases: tuple[str, ...] = ()
54
+ description: str | None = None
55
+ raw: Any = None
56
+
57
+ @property
58
+ def canonical_value(self) -> Any:
59
+ return self.id
60
+
61
+ def searchable_values(self) -> list[str]:
62
+ values = [str(self.id), self.slug, self.label, *self.aliases]
63
+ if self.description:
64
+ values.append(self.description)
65
+ return [v for v in values if v]
66
+
67
+
68
+ def extract_option_sets(definition: Any) -> dict[str, list[Candidate]]:
69
+ """Discover option/enum lists from a schema-free definitions payload.
70
+
71
+ The SV definitions may evolve. This function deliberately looks for
72
+ common JSON patterns instead of relying on one fixed schema.
73
+ """
74
+
75
+ found: dict[str, list[Candidate]] = {}
76
+
77
+ def add(field: str, candidates: Iterable[Candidate]) -> None:
78
+ clean_field = normalize_field(field)
79
+ if not clean_field:
80
+ return
81
+ unique: dict[tuple[str, str, str], Candidate] = {}
82
+ for candidate in candidates:
83
+ key = (str(candidate.id), candidate.slug, candidate.label)
84
+ unique[key] = Candidate(
85
+ field=clean_field,
86
+ id=candidate.id,
87
+ label=candidate.label,
88
+ slug=candidate.slug,
89
+ aliases=candidate.aliases,
90
+ description=candidate.description,
91
+ raw=candidate.raw,
92
+ )
93
+ if not unique:
94
+ return
95
+ existing = found.setdefault(clean_field, [])
96
+ seen = {(str(c.id), c.slug, c.label) for c in existing}
97
+ for key, candidate in unique.items():
98
+ if key not in seen:
99
+ existing.append(candidate)
100
+
101
+ def visit(node: Any, path: list[str]) -> None:
102
+ if isinstance(node, dict):
103
+ # A mapping like {"15": "Meta Description", "32": "Product Description"}.
104
+ mapping_candidates = candidates_from_mapping(path[-1] if path else "options", node)
105
+ if mapping_candidates and len(mapping_candidates) >= 2:
106
+ add(path[-1] if path else "options", mapping_candidates)
107
+
108
+ for key, value in node.items():
109
+ key_str = str(key)
110
+ if isinstance(value, list):
111
+ field = path[-1] if key_str.lower() in OPTION_CONTAINER_KEYS and path else key_str
112
+ candidates = candidates_from_list(field, value)
113
+ if candidates:
114
+ add(field, candidates)
115
+ elif isinstance(value, dict):
116
+ if key_str.lower() in OPTION_CONTAINER_KEYS and path:
117
+ candidates = candidates_from_mapping(path[-1], value)
118
+ if candidates:
119
+ add(path[-1], candidates)
120
+ else:
121
+ nested_candidates = candidates_from_mapping(key_str, value)
122
+ if nested_candidates and len(nested_candidates) >= 2:
123
+ add(key_str, nested_candidates)
124
+ visit(value, [*path, key_str])
125
+ else:
126
+ continue
127
+ elif isinstance(node, list):
128
+ if path:
129
+ candidates = candidates_from_list(path[-1], node)
130
+ if candidates:
131
+ add(path[-1], candidates)
132
+ for item in node:
133
+ visit(item, path)
134
+
135
+ def visit_described_options(node: Any) -> None:
136
+ if isinstance(node, dict):
137
+ field_name = first_present(node, PARAM_NAME_KEYS)
138
+ description = first_present(node, ("description", "Description", "help", "Help"))
139
+ if isinstance(field_name, str):
140
+ if isinstance(description, list):
141
+ described_candidates = candidates_from_labeled_list(field_name, description)
142
+ else:
143
+ described_candidates = candidates_from_valid_values(field_name, description)
144
+ if described_candidates:
145
+ add(field_name, described_candidates)
146
+ for value in node.values():
147
+ visit_described_options(value)
148
+ elif isinstance(node, list):
149
+ for item in node:
150
+ visit_described_options(item)
151
+
152
+ visit(definition, [])
153
+ visit_described_options(definition)
154
+ return found
155
+
156
+
157
+ def extract_parameter_names(definition: Any) -> set[str]:
158
+ names: set[str] = set()
159
+
160
+ def visit(node: Any, parent_key: str | None = None) -> None:
161
+ if isinstance(node, dict):
162
+ for key, value in node.items():
163
+ key_str = str(key)
164
+ names.add(normalize_field(key_str))
165
+ if key_str in PARAM_NAME_KEYS and isinstance(value, str):
166
+ names.add(normalize_field(value))
167
+ visit(value, key_str)
168
+ elif isinstance(node, list):
169
+ for item in node:
170
+ visit(item, parent_key)
171
+
172
+ target = definition.get("api_input", definition) if isinstance(definition, dict) else definition
173
+ visit(target)
174
+ return {name for name in names if name}
175
+
176
+
177
+ def candidates_from_list(field: str, values: list[Any]) -> list[Candidate]:
178
+ candidates: list[Candidate] = []
179
+ for item in values:
180
+ candidate = candidate_from_item(field, item)
181
+ if candidate:
182
+ candidates.append(candidate)
183
+ return candidates
184
+
185
+
186
+ def candidates_from_mapping(field: str, mapping: dict[Any, Any]) -> list[Candidate]:
187
+ if not mapping:
188
+ return []
189
+ candidates: list[Candidate] = []
190
+ for key, value in mapping.items():
191
+ # Avoid turning arbitrary schema objects into options.
192
+ if str(key) in {"type", "required", "optional", "parameters", "properties", "description"}:
193
+ return []
194
+ if isinstance(value, (str, int, float, bool)):
195
+ label = str(value)
196
+ candidates.append(
197
+ Candidate(field=field, id=coerce_id(key), label=label, slug=slugify(label), raw={key: value})
198
+ )
199
+ elif isinstance(value, dict):
200
+ candidate = candidate_from_item(field, {"id": key, **value})
201
+ if candidate:
202
+ candidates.append(candidate)
203
+ else:
204
+ return []
205
+ return candidates
206
+
207
+
208
+ def candidate_from_item(field: str, item: Any) -> Candidate | None:
209
+ if isinstance(item, (str, int, float, bool)):
210
+ label = str(item)
211
+ return Candidate(field=field, id=coerce_id(item), label=label, slug=slugify(label), raw=item)
212
+ if not isinstance(item, dict):
213
+ return None
214
+
215
+ # API parameter definition objects such as {"field": "kw", "type": "string",
216
+ # "description": "..."} are not enum candidates. Valid values embedded in
217
+ # descriptions are extracted separately by extract_option_sets().
218
+ lowered_keys = {str(key).lower() for key in item}
219
+ enumish_keys = {"id", "value", "slug", "aliases", "options", "values", "enum", "choices"}
220
+ if "field" in lowered_keys and "type" in lowered_keys and not (lowered_keys & enumish_keys):
221
+ return None
222
+
223
+ identifier = first_present(item, ID_KEYS)
224
+ label = first_present(item, LABEL_KEYS)
225
+ slug = first_present(item, SLUG_KEYS)
226
+ description = first_present(item, ("description", "Description", "help", "Help"))
227
+ aliases_raw = first_present(item, ALIAS_KEYS)
228
+ aliases = normalize_aliases(aliases_raw)
229
+
230
+ if identifier is None and slug is not None:
231
+ identifier = slug
232
+ if label is None and slug is not None:
233
+ label = str(slug).replace("-", " ").replace("_", " ").title()
234
+ if label is None and identifier is not None:
235
+ label = str(identifier)
236
+ if identifier is None and label is not None:
237
+ identifier = slugify(label)
238
+ if label is None or identifier is None:
239
+ return None
240
+ slug_value = str(slug) if slug is not None else slugify(label)
241
+
242
+ # Include string identifiers as aliases when they differ from the label/slug.
243
+ extra_aliases = []
244
+ if isinstance(identifier, str) and identifier not in {label, slug_value}:
245
+ extra_aliases.append(identifier)
246
+ all_aliases = tuple(dict.fromkeys([*aliases, *extra_aliases]))
247
+
248
+ return Candidate(
249
+ field=field,
250
+ id=coerce_id(identifier),
251
+ label=str(label),
252
+ slug=slugify(slug_value),
253
+ aliases=all_aliases,
254
+ description=str(description) if description is not None else None,
255
+ raw=item,
256
+ )
257
+
258
+
259
+ def candidates_from_valid_values(field: str, description: Any) -> list[Candidate]:
260
+ text = description_to_text(description)
261
+ if not text:
262
+ return []
263
+ match = VALID_VALUES_PATTERN.search(text)
264
+ if not match:
265
+ return []
266
+ values_text = match.group(1)
267
+ # Stop at the first sentence when the definitions include prose after the list.
268
+ values_text = values_text.split(".", 1)[0]
269
+ values = [part.strip().strip(".:") for part in values_text.split(",")]
270
+ candidates: list[Candidate] = []
271
+ for value in values:
272
+ if not value:
273
+ continue
274
+ candidates.append(Candidate(field=field, id=value, label=value, slug=slugify(value), raw=value))
275
+ return candidates
276
+
277
+
278
+ def candidates_from_labeled_list(field: str, items: list[Any]) -> list[Candidate]:
279
+ """Parse ``"id: label"`` strings from an api_input description list."""
280
+ candidates: list[Candidate] = []
281
+ for item in items:
282
+ if not isinstance(item, str):
283
+ continue
284
+ m = re.match(r"^(\S+)\s*:\s*(.+)$", item.strip())
285
+ if not m:
286
+ continue
287
+ id_str, label = m.group(1).strip(), m.group(2).strip()
288
+ candidates.append(Candidate(field=field, id=coerce_id(id_str), label=label, slug=slugify(label), raw=item))
289
+ return candidates if len(candidates) >= 2 else []
290
+
291
+
292
+ def description_to_text(description: Any) -> str:
293
+ if description is None:
294
+ return ""
295
+ if isinstance(description, str):
296
+ return description
297
+ if isinstance(description, (list, tuple, set)):
298
+ return "\n".join(str(item) for item in description)
299
+ return str(description)
300
+
301
+
302
+ def first_present(mapping: dict[str, Any], keys: Iterable[str]) -> Any:
303
+ for key in keys:
304
+ if key in mapping and mapping[key] not in (None, ""):
305
+ return mapping[key]
306
+ lowered = {str(k).lower(): v for k, v in mapping.items()}
307
+ for key in keys:
308
+ value = lowered.get(key.lower())
309
+ if value not in (None, ""):
310
+ return value
311
+ return None
312
+
313
+
314
+ def normalize_aliases(value: Any) -> tuple[str, ...]:
315
+ if value is None:
316
+ return ()
317
+ if isinstance(value, str):
318
+ # Keep phrases intact where possible, but support comma/pipe-separated aliases.
319
+ parts = [part.strip() for part in value.replace("|", ",").split(",")]
320
+ return tuple(part for part in parts if part)
321
+ if isinstance(value, (list, tuple, set)):
322
+ return tuple(str(item).strip() for item in value if str(item).strip())
323
+ return (str(value),)
324
+
325
+
326
+ def coerce_id(value: Any) -> Any:
327
+ if isinstance(value, str) and value.strip().isdigit():
328
+ return int(value.strip())
329
+ return value
330
+
331
+
332
+ def normalize_field(field: str) -> str:
333
+ return str(field).strip().replace("-", "_").replace(" ", "_").lower()
334
+
335
+
336
+ def resolve_api_field(tool: str, cli_field: str, definition: Any) -> tuple[str, list[Candidate] | None]:
337
+ """Choose the best API field name for a friendly CLI field."""
338
+
339
+ option_sets = extract_option_sets(definition)
340
+ parameter_names = extract_parameter_names(definition)
341
+ candidates = field_candidates(tool, cli_field)
342
+ normalized_candidates = [normalize_field(candidate) for candidate in candidates]
343
+
344
+ for candidate in normalized_candidates:
345
+ if candidate in option_sets:
346
+ return candidate, option_sets[candidate]
347
+ for candidate in normalized_candidates:
348
+ if candidate in parameter_names:
349
+ return candidate, option_sets.get(candidate)
350
+ # Fall back by normalized compact comparison.
351
+ compact_candidates = {compact_text(candidate): candidate for candidate in normalized_candidates}
352
+ for existing_field in option_sets:
353
+ if compact_text(existing_field) in compact_candidates:
354
+ return existing_field, option_sets[existing_field]
355
+ for existing_field in parameter_names:
356
+ if compact_text(existing_field) in compact_candidates:
357
+ return existing_field, option_sets.get(existing_field)
358
+ return normalized_candidates[0] if normalized_candidates else normalize_field(cli_field), None
359
+
360
+
361
+ def resolve_enum_value(
362
+ field: str,
363
+ value: Any,
364
+ candidates: list[Candidate],
365
+ *,
366
+ strict: bool = False,
367
+ fuzzy: bool = True,
368
+ non_interactive: bool = False,
369
+ ) -> Any:
370
+ """Resolve a user value into the candidate's canonical API value."""
371
+
372
+ if value is None:
373
+ return None
374
+ if isinstance(value, (list, tuple)):
375
+ return [
376
+ resolve_enum_value(field, item, candidates, strict=strict, fuzzy=fuzzy, non_interactive=non_interactive)
377
+ for item in value
378
+ ]
379
+ text = str(value).strip()
380
+ if not text:
381
+ raise InvalidInputError(f'Could not resolve --{field}: empty value.')
382
+
383
+ # 1. Numeric ID exact match.
384
+ if text.isdigit():
385
+ numeric = int(text)
386
+ numeric_matches = [candidate for candidate in candidates if candidate.id == numeric]
387
+ if len(numeric_matches) == 1:
388
+ return numeric_matches[0].canonical_value
389
+ if len(numeric_matches) > 1:
390
+ raise ambiguous_error(field, text, numeric_matches)
391
+
392
+ # 2. Exact slug match.
393
+ slug_matches = [candidate for candidate in candidates if str(candidate.slug) == text]
394
+ if len(slug_matches) == 1:
395
+ return slug_matches[0].canonical_value
396
+ if len(slug_matches) > 1:
397
+ raise ambiguous_error(field, text, slug_matches)
398
+
399
+ # 3. Exact canonical API value match. This covers string-valued enums.
400
+ canonical_matches = [candidate for candidate in candidates if str(candidate.id) == text]
401
+ if len(canonical_matches) == 1:
402
+ return canonical_matches[0].canonical_value
403
+ if len(canonical_matches) > 1:
404
+ raise ambiguous_error(field, text, canonical_matches)
405
+
406
+ if strict:
407
+ allowed = ", ".join(format_candidate(candidate) for candidate in candidates[:10])
408
+ raise InvalidInputError(
409
+ f'Could not resolve --{field} "{text}" in strict mode. Use a numeric ID, exact slug, '
410
+ f"or exact canonical API value. Examples: {allowed}"
411
+ )
412
+
413
+ # 4. Exact alias/label matches are intentionally non-strict.
414
+ exact_groups = [
415
+ lambda c: text in {str(alias) for alias in c.aliases},
416
+ lambda c: str(c.label) == text,
417
+ ]
418
+ for matcher in exact_groups:
419
+ matches = [candidate for candidate in candidates if matcher(candidate)]
420
+ if len(matches) == 1:
421
+ return matches[0].canonical_value
422
+ if len(matches) > 1:
423
+ raise ambiguous_error(field, text, matches)
424
+
425
+ norm = normalize_text(text)
426
+ compact = compact_text(text)
427
+
428
+ # 5. Normalized exact match.
429
+ normalized_matches = [
430
+ candidate
431
+ for candidate in candidates
432
+ if any(normalize_text(value) == norm or compact_text(value) == compact for value in candidate.searchable_values())
433
+ ]
434
+ if len(normalized_matches) == 1:
435
+ return normalized_matches[0].canonical_value
436
+ if len(normalized_matches) > 1:
437
+ raise ambiguous_error(field, text, normalized_matches)
438
+
439
+ # 6. Prefix match.
440
+ prefix_matches = [
441
+ candidate
442
+ for candidate in candidates
443
+ if any(normalize_text(value).startswith(norm) or compact_text(value).startswith(compact) for value in candidate.searchable_values())
444
+ ]
445
+ if len(prefix_matches) == 1:
446
+ return prefix_matches[0].canonical_value
447
+ if len(prefix_matches) > 1:
448
+ raise ambiguous_error(field, text, prefix_matches)
449
+
450
+ # 7. Contains match.
451
+ contains_matches = [
452
+ candidate
453
+ for candidate in candidates
454
+ if any(norm in normalize_text(value) or compact in compact_text(value) for value in candidate.searchable_values())
455
+ ]
456
+ if len(contains_matches) == 1:
457
+ return contains_matches[0].canonical_value
458
+ if len(contains_matches) > 1:
459
+ raise ambiguous_error(field, text, contains_matches)
460
+
461
+ # 8. Fuzzy match.
462
+ if fuzzy:
463
+ scored = sorted(
464
+ ((best_score(text, candidate), candidate) for candidate in candidates),
465
+ key=lambda pair: pair[0],
466
+ reverse=True,
467
+ )
468
+ if scored:
469
+ best, best_candidate = scored[0]
470
+ second = scored[1][0] if len(scored) > 1 else 0
471
+ if best >= 95 or (best >= 85 and best - second >= 10):
472
+ return best_candidate.canonical_value
473
+ if best >= 70:
474
+ raise ambiguous_error(field, text, [candidate for score, candidate in scored[:5] if score >= 70])
475
+
476
+ suggestions = ", ".join(format_candidate(candidate) for candidate in candidates[:10])
477
+ raise InvalidInputError(
478
+ f'Could not resolve --{field} "{text}". Try `sv options TOOL {field} --search {text}`. '
479
+ f"Examples: {suggestions}"
480
+ )
481
+
482
+
483
+ def best_score(text: str, candidate: Candidate) -> float:
484
+ norm = normalize_text(text)
485
+ compact = compact_text(text)
486
+ scores = []
487
+ for value in candidate.searchable_values():
488
+ scores.append(100 * difflib.SequenceMatcher(None, norm, normalize_text(value)).ratio())
489
+ scores.append(100 * difflib.SequenceMatcher(None, compact, compact_text(value)).ratio())
490
+ return max(scores or [0.0])
491
+
492
+
493
+ def ambiguous_error(field: str, value: str, matches: list[Candidate]) -> AmbiguousMatchError:
494
+ lines = [f'Error: Could not resolve --{field} "{value}".', "It matched multiple options:"]
495
+ for index, candidate in enumerate(matches[:10], start=1):
496
+ lines.append(f"{index}. {format_candidate(candidate)}")
497
+ lines.append("Please rerun with one of:")
498
+ for candidate in matches[:5]:
499
+ lines.append(f"--{field} {candidate.slug}")
500
+ return AmbiguousMatchError("\n".join(lines))
501
+
502
+
503
+ def format_candidate(candidate: Candidate) -> str:
504
+ return f"{candidate.label} {candidate.slug} id: {candidate.id}"
505
+
506
+
507
+ def search_candidates(candidates: list[Candidate], query: str | None) -> list[Candidate]:
508
+ if not query:
509
+ return candidates
510
+ norm = normalize_text(query)
511
+ compact = compact_text(query)
512
+ filtered = []
513
+ for candidate in candidates:
514
+ if any(norm in normalize_text(value) or compact in compact_text(value) for value in candidate.searchable_values()):
515
+ filtered.append(candidate)
516
+ if filtered:
517
+ return filtered
518
+ scored = sorted(
519
+ ((best_score(query, candidate), candidate) for candidate in candidates),
520
+ key=lambda pair: pair[0],
521
+ reverse=True,
522
+ )
523
+ return [candidate for score, candidate in scored if score >= 60][:20]
File without changes
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class APIResponseModel(BaseModel):
9
+ data: Any
10
+ status_code: int
11
+ elapsed_seconds: float
12
+ url: str
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ProfileConfig(BaseModel):
7
+ api_key: str | None = None
8
+ base_url: str
9
+
10
+
11
+ class CLIConfig(BaseModel):
12
+ default_profile: str = "default"
13
+ profiles: dict[str, ProfileConfig]
@@ -0,0 +1,17 @@
1
+ """Pydantic models used by tests and downstream consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ToolDefinition(BaseModel):
11
+ tool: str
12
+ endpoint: str | None = None
13
+ definitions_url: str | None = Field(default=None, alias="definitionsUrl")
14
+ definition: Any = None
15
+ last_fetched: str | None = None
16
+
17
+ model_config = {"populate_by_name": True, "extra": "allow"}