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
sv_cli/main.py ADDED
@@ -0,0 +1,905 @@
1
+ """SV CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import getpass
7
+ import sys
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from . import __version__
15
+ from .adapters import TOOL_ADAPTERS, ToolAdapter, adapter_as_dict, all_command_names
16
+ from .api_client import APIClient
17
+ from .config import (
18
+ DEFAULT_BASE_URL,
19
+ clear_api_key,
20
+ create_profile,
21
+ delete_profile,
22
+ get_config_value,
23
+ load_config,
24
+ masked_config,
25
+ reset_config,
26
+ set_api_key,
27
+ set_config_value,
28
+ use_profile,
29
+ )
30
+ from .definitions import DefinitionsManager
31
+ from .errors import CLIError, ConfigError, InvalidInputError
32
+ from .executor import RuntimeOptions, WaitOptions, execute_tool, load_json_payload
33
+ from .formatter import print_output
34
+ from .resolver import extract_option_sets, resolve_api_field, search_candidates
35
+ from .tasks import get_task_tool, result_payload, status_payload
36
+ from .utils import coerce_jsonish, coerce_mapping_values, parse_key_value_args, read_text_source
37
+
38
+ console = Console()
39
+ err_console = Console(stderr=True)
40
+ COMMON_CONTEXT_SETTINGS = {"allow_extra_args": True, "ignore_unknown_options": True}
41
+
42
+ app = typer.Typer(
43
+ name="sv",
44
+ help="Definition-driven CLI for SV AI API tools.",
45
+ no_args_is_help=True,
46
+ )
47
+
48
+
49
+ def version_callback(value: bool) -> None:
50
+ if value:
51
+ console.print(f"sv-cli {__version__}")
52
+ raise typer.Exit(0)
53
+
54
+
55
+ @app.callback()
56
+ def main_callback(
57
+ ctx: typer.Context,
58
+ version: bool = typer.Option(False, "--version", callback=version_callback, is_eager=True, help="Show version and exit."),
59
+ profile: str | None = typer.Option(None, "--profile", help="Configuration profile to use."),
60
+ api_key: str | None = typer.Option(None, "--api-key", help="SV API key. Prefer SV_API_KEY or auth set."),
61
+ base_url: str | None = typer.Option(None, "--base-url", help="Override API base URL."),
62
+ output_format: str | None = typer.Option(None, "--format", help="pretty|json|table|csv|markdown|text"),
63
+ output: str | None = typer.Option(None, "--output", help="Write output to a file."),
64
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress normal output."),
65
+ verbose: bool = typer.Option(False, "--verbose", help="Show additional progress details."),
66
+ debug: bool = typer.Option(False, "--debug", help="Show masked payloads, endpoints, and HTTP timing."),
67
+ strict: bool = typer.Option(False, "--strict", help="Use strict enum matching for agents and CI."),
68
+ no_fuzzy: bool = typer.Option(False, "--no-fuzzy", help="Disable fuzzy enum matching."),
69
+ non_interactive: bool = typer.Option(False, "--non-interactive", help="Disable prompts and interactive choices."),
70
+ ) -> None:
71
+ cfg = load_config()
72
+ defaults = cfg.get("defaults", {})
73
+ if api_key:
74
+ err_console.print("[yellow]Warning:[/yellow] --api-key can be stored in shell history. Prefer SV_API_KEY or `sv auth set`.")
75
+ ctx.obj = RuntimeOptions(
76
+ profile=profile,
77
+ api_key=api_key,
78
+ base_url=base_url,
79
+ output_format=output_format or defaults.get("format", "pretty"),
80
+ output=output,
81
+ quiet=quiet,
82
+ verbose=verbose,
83
+ debug=debug,
84
+ strict=strict or bool(defaults.get("strict", False)),
85
+ no_fuzzy=no_fuzzy or not bool(defaults.get("fuzzy", True)),
86
+ non_interactive=non_interactive,
87
+ )
88
+
89
+
90
+ def fail(exc: Exception) -> None:
91
+ if isinstance(exc, CLIError):
92
+ err_console.print(f"[red]Error:[/red] {exc.message}")
93
+ raise typer.Exit(exc.exit_code) from exc
94
+ err_console.print(f"[red]Error:[/red] {exc}")
95
+ raise typer.Exit(1) from exc
96
+
97
+
98
+ def runtime_from_ctx(ctx: typer.Context) -> RuntimeOptions:
99
+ runtime = ctx.obj if isinstance(ctx.obj, RuntimeOptions) else RuntimeOptions()
100
+ return copy.copy(runtime)
101
+
102
+
103
+ def apply_runtime_overrides(runtime: RuntimeOptions, overrides: dict[str, Any]) -> RuntimeOptions:
104
+ runtime = copy.copy(runtime)
105
+ mapping = {
106
+ "profile": "profile",
107
+ "api_key": "api_key",
108
+ "base_url": "base_url",
109
+ "format": "output_format",
110
+ "output_format": "output_format",
111
+ "output": "output",
112
+ "quiet": "quiet",
113
+ "verbose": "verbose",
114
+ "debug": "debug",
115
+ "strict": "strict",
116
+ "no_fuzzy": "no_fuzzy",
117
+ "non_interactive": "non_interactive",
118
+ }
119
+ for cli_key, attr in mapping.items():
120
+ if cli_key in overrides and overrides[cli_key] is not None:
121
+ value = overrides.pop(cli_key)
122
+ if attr in {"quiet", "verbose", "debug", "strict", "no_fuzzy", "non_interactive"}:
123
+ value = bool(value)
124
+ setattr(runtime, attr, value)
125
+ return runtime
126
+
127
+
128
+ def pop_wait_options(params: dict[str, Any]) -> WaitOptions:
129
+ wait = bool(params.pop("wait", False))
130
+ timeout = int(params.pop("timeout", 600) or 600)
131
+ poll_interval = int(params.pop("poll_interval", 5) or 5)
132
+ no_progress = bool(params.pop("no_progress", False))
133
+ return WaitOptions(wait=wait, timeout=timeout, poll_interval=poll_interval, no_progress=no_progress)
134
+
135
+
136
+ def run_tool_action(
137
+ ctx: typer.Context,
138
+ *,
139
+ tool: str,
140
+ action: str,
141
+ params: dict[str, Any],
142
+ method: str = "POST",
143
+ ) -> None:
144
+ try:
145
+ runtime = runtime_from_ctx(ctx)
146
+ params = {key: value for key, value in params.items() if value not in (None, False)}
147
+ params.update(parse_key_value_args(ctx.args))
148
+ params = coerce_mapping_values(params)
149
+ runtime = apply_runtime_overrides(runtime, params)
150
+ wait_options = pop_wait_options(params)
151
+ execute_tool(
152
+ tool_name=tool,
153
+ action=action,
154
+ params=params,
155
+ runtime=runtime,
156
+ wait_options=wait_options,
157
+ method=method,
158
+ console=console,
159
+ )
160
+ except Exception as exc: # noqa: BLE001
161
+ fail(exc)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Auth commands
166
+ # ---------------------------------------------------------------------------
167
+ auth_app = typer.Typer(help="Configure API-key authentication.", no_args_is_help=True)
168
+
169
+
170
+ def auth_set_impl(ctx: typer.Context, api_key: str | None, profile: str | None) -> None:
171
+ runtime = runtime_from_ctx(ctx)
172
+ target_profile = profile or runtime.profile
173
+ if not api_key:
174
+ api_key = getpass.getpass("SV API key: ").strip()
175
+ if not api_key:
176
+ raise InvalidInputError("API key cannot be empty.")
177
+ set_api_key(api_key, target_profile)
178
+ console.print("API key saved to local profile config. It will be masked in status and debug output.")
179
+
180
+
181
+ @auth_app.command("set")
182
+ def auth_set(
183
+ ctx: typer.Context,
184
+ api_key: str | None = typer.Option(None, "--api-key", help="API key to store. Omit to enter securely."),
185
+ profile: str | None = typer.Option(None, "--profile", help="Profile to update."),
186
+ ) -> None:
187
+ try:
188
+ auth_set_impl(ctx, api_key, profile)
189
+ except Exception as exc: # noqa: BLE001
190
+ fail(exc)
191
+
192
+
193
+ @auth_app.command("login")
194
+ def auth_login(
195
+ ctx: typer.Context,
196
+ api_key: str | None = typer.Option(None, "--api-key", help="API key to store. Omit to enter securely."),
197
+ profile: str | None = typer.Option(None, "--profile", help="Profile to update."),
198
+ ) -> None:
199
+ try:
200
+ auth_set_impl(ctx, api_key, profile)
201
+ except Exception as exc: # noqa: BLE001
202
+ fail(exc)
203
+
204
+
205
+ @auth_app.command("status")
206
+ def auth_status(ctx: typer.Context) -> None:
207
+ try:
208
+ cfg = masked_config()
209
+ runtime = runtime_from_ctx(ctx)
210
+ profile_name = runtime.profile or cfg.get("default_profile") or "default"
211
+ profile = cfg.get("profiles", {}).get(profile_name, {})
212
+ env = __import__("os").environ
213
+ env_present = "SV_API_KEY" in env
214
+ legacy_env_present = "SEOVENDOR_API_KEY" in env
215
+ table = Table("Source", "Status")
216
+ table.add_row("--api-key", "provided" if runtime.api_key else "not provided")
217
+ table.add_row("SV_API_KEY", "set" if env_present else "not set")
218
+ table.add_row("SEOVENDOR_API_KEY", "set (legacy)" if legacy_env_present else "not set")
219
+ table.add_row(f"profile:{profile_name}", "set" if profile.get("api_key") else "not set")
220
+ table.add_row("base_url", str(profile.get("base_url") or DEFAULT_BASE_URL))
221
+ console.print(table)
222
+ except Exception as exc: # noqa: BLE001
223
+ fail(exc)
224
+
225
+
226
+ @auth_app.command("clear")
227
+ def auth_clear(
228
+ ctx: typer.Context,
229
+ profile: str | None = typer.Option(None, "--profile", help="Profile to clear."),
230
+ ) -> None:
231
+ try:
232
+ runtime = runtime_from_ctx(ctx)
233
+ clear_api_key(profile or runtime.profile)
234
+ console.print("API key cleared from local profile config.")
235
+ except Exception as exc: # noqa: BLE001
236
+ fail(exc)
237
+
238
+
239
+ app.add_typer(auth_app, name="auth")
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Profile commands
244
+ # ---------------------------------------------------------------------------
245
+ profile_app = typer.Typer(help="Manage profiles for separate keys/environments.", no_args_is_help=True)
246
+
247
+
248
+ @profile_app.command("list")
249
+ def profile_list() -> None:
250
+ try:
251
+ cfg = masked_config()
252
+ default_profile = cfg.get("default_profile")
253
+ table = Table("Profile", "Default", "Base URL", "API Key")
254
+ for name, prof in sorted(cfg.get("profiles", {}).items()):
255
+ table.add_row(name, "yes" if name == default_profile else "", str(prof.get("base_url")), "set" if prof.get("api_key") else "")
256
+ console.print(table)
257
+ except Exception as exc: # noqa: BLE001
258
+ fail(exc)
259
+
260
+
261
+ @profile_app.command("create")
262
+ def profile_create(
263
+ name: str = typer.Argument(..., help="Profile name."),
264
+ base_url: str | None = typer.Option(None, "--base-url", help="Profile base URL."),
265
+ api_key: str | None = typer.Option(None, "--api-key", help="Optional API key."),
266
+ ) -> None:
267
+ try:
268
+ create_profile(name, base_url=base_url, api_key=api_key)
269
+ console.print(f'Profile "{name}" created.')
270
+ except Exception as exc: # noqa: BLE001
271
+ fail(exc)
272
+
273
+
274
+ @profile_app.command("use")
275
+ def profile_use(name: str = typer.Argument(..., help="Profile name.")) -> None:
276
+ try:
277
+ use_profile(name)
278
+ console.print(f'Now using profile "{name}" by default.')
279
+ except Exception as exc: # noqa: BLE001
280
+ fail(exc)
281
+
282
+
283
+ @profile_app.command("delete")
284
+ def profile_delete(name: str = typer.Argument(..., help="Profile name.")) -> None:
285
+ try:
286
+ delete_profile(name)
287
+ console.print(f'Profile "{name}" deleted.')
288
+ except Exception as exc: # noqa: BLE001
289
+ fail(exc)
290
+
291
+
292
+ app.add_typer(profile_app, name="profile")
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Config commands
297
+ # ---------------------------------------------------------------------------
298
+ config_app = typer.Typer(help="Show and edit CLI configuration.", no_args_is_help=True)
299
+
300
+
301
+ @config_app.command("show")
302
+ def config_show(ctx: typer.Context) -> None:
303
+ try:
304
+ runtime = runtime_from_ctx(ctx)
305
+ print_output(console, masked_config(), runtime.output_format, runtime.output)
306
+ except Exception as exc: # noqa: BLE001
307
+ fail(exc)
308
+
309
+
310
+ @config_app.command("get")
311
+ def config_get(
312
+ key: str = typer.Argument(..., help="Config key, e.g. base_url or cache_ttl_seconds."),
313
+ profile: str | None = typer.Option(None, "--profile", help="Profile for profile-scoped keys."),
314
+ ) -> None:
315
+ try:
316
+ console.print(get_config_value(key, profile))
317
+ except Exception as exc: # noqa: BLE001
318
+ fail(exc)
319
+
320
+
321
+ @config_app.command("set")
322
+ def config_set(
323
+ key: str = typer.Argument(...),
324
+ value: str = typer.Argument(...),
325
+ profile: str | None = typer.Option(None, "--profile", help="Profile for profile-scoped keys."),
326
+ ) -> None:
327
+ try:
328
+ set_config_value(key, coerce_jsonish(value), profile)
329
+ console.print(f"Set {key}.")
330
+ except Exception as exc: # noqa: BLE001
331
+ fail(exc)
332
+
333
+
334
+ @config_app.command("reset")
335
+ def config_reset() -> None:
336
+ try:
337
+ reset_config()
338
+ console.print("Configuration reset.")
339
+ except Exception as exc: # noqa: BLE001
340
+ fail(exc)
341
+
342
+
343
+ app.add_typer(config_app, name="config")
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Definition commands
348
+ # ---------------------------------------------------------------------------
349
+ definitions_app = typer.Typer(help="Fetch, inspect, refresh, and clear API definitions cache.", no_args_is_help=True)
350
+
351
+
352
+ @definitions_app.command("refresh")
353
+ def definitions_refresh(
354
+ ctx: typer.Context,
355
+ tool: str | None = typer.Option(None, "--tool", help="Refresh one tool only."),
356
+ ) -> None:
357
+ try:
358
+ runtime = runtime_from_ctx(ctx)
359
+ manager = DefinitionsManager(runtime.base_url)
360
+ if tool:
361
+ entry = manager.refresh_tool(tool)
362
+ console.print(f'Refreshed definitions for {entry["tool"]}.')
363
+ else:
364
+ cache = manager.refresh_all()
365
+ console.print(f"Refreshed API root and {len(cache.get('tools', {}))} tool definitions.")
366
+ for warning in cache.get("warnings", []):
367
+ err_console.print(f"[yellow]Warning:[/yellow] {warning}")
368
+ except Exception as exc: # noqa: BLE001
369
+ fail(exc)
370
+
371
+
372
+ @definitions_app.command("list")
373
+ def definitions_list(ctx: typer.Context) -> None:
374
+ try:
375
+ runtime = runtime_from_ctx(ctx)
376
+ manager = DefinitionsManager(runtime.base_url)
377
+ cache = manager.get_cache(allow_stale=True)
378
+ rows = []
379
+ for tool_name, entry in sorted(cache.get("tools", {}).items()):
380
+ rows.append(
381
+ {
382
+ "tool": tool_name,
383
+ "command": entry.get("display_name") or tool_name,
384
+ "endpoint": entry.get("endpoint"),
385
+ "definitions_url": entry.get("definitions_url"),
386
+ "last_fetched": entry.get("last_fetched"),
387
+ }
388
+ )
389
+ print_output(console, rows, "table" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
390
+ for warning in cache.get("warnings", []):
391
+ err_console.print(f"[yellow]Warning:[/yellow] {warning}")
392
+ except Exception as exc: # noqa: BLE001
393
+ fail(exc)
394
+
395
+
396
+ @definitions_app.command("show")
397
+ def definitions_show(
398
+ ctx: typer.Context,
399
+ tool: str = typer.Argument(..., help="Tool name or alias, e.g. seogpt or geo-audit."),
400
+ ) -> None:
401
+ try:
402
+ runtime = runtime_from_ctx(ctx)
403
+ manager = DefinitionsManager(runtime.base_url)
404
+ entry = manager.get_tool(tool)
405
+ print_output(console, entry, "json" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
406
+ except Exception as exc: # noqa: BLE001
407
+ fail(exc)
408
+
409
+
410
+ @definitions_app.command("clear")
411
+ def definitions_clear(ctx: typer.Context) -> None:
412
+ try:
413
+ runtime = runtime_from_ctx(ctx)
414
+ manager = DefinitionsManager(runtime.base_url)
415
+ removed = manager.clear_cache()
416
+ console.print("Definitions cache cleared." if removed else "Definitions cache was already empty.")
417
+ except Exception as exc: # noqa: BLE001
418
+ fail(exc)
419
+
420
+
421
+ app.add_typer(definitions_app, name="definitions")
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Options discovery command
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def split_positionals_and_options(args: list[str]) -> tuple[list[str], dict[str, Any]]:
429
+ options = parse_key_value_args(args)
430
+ positionals: list[str] = []
431
+ i = 0
432
+ while i < len(args):
433
+ token = args[i]
434
+ if token.startswith("--"):
435
+ if "=" not in token and i + 1 < len(args) and not args[i + 1].startswith("--"):
436
+ i += 2
437
+ else:
438
+ i += 1
439
+ continue
440
+ positionals.append(token)
441
+ i += 1
442
+ return positionals, options
443
+
444
+
445
+ def show_options(ctx: typer.Context, tool: str | None, field: str | None, search: str | None) -> None:
446
+ runtime = runtime_from_ctx(ctx)
447
+ manager = DefinitionsManager(runtime.base_url)
448
+ cache = manager.get_cache(allow_stale=True)
449
+ if not tool:
450
+ rows = []
451
+ for tool_name, entry in sorted(cache.get("tools", {}).items()):
452
+ option_sets = extract_option_sets(entry.get("definition") or {})
453
+ rows.append({"tool": tool_name, "command": entry.get("display_name") or tool_name, "option_sets": len(option_sets)})
454
+ print_output(console, rows, "table" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
455
+ return
456
+
457
+ entry = manager.get_tool(tool)
458
+ canonical = entry["tool"]
459
+ definition = entry.get("definition") or {}
460
+ option_sets = extract_option_sets(definition)
461
+ if not field:
462
+ rows = [{"field": name, "count": len(candidates)} for name, candidates in sorted(option_sets.items())]
463
+ print_output(console, rows, "table" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
464
+ return
465
+
466
+ api_field, candidates = resolve_api_field(canonical, field, definition)
467
+ candidates = candidates or option_sets.get(field.replace("-", "_").lower())
468
+ if not candidates:
469
+ available = ", ".join(sorted(option_sets)) or "none found"
470
+ raise ConfigError(f'No options found for "{field}" on tool "{canonical}". Available fields: {available}')
471
+ candidates = search_candidates(candidates, search)
472
+ rows = [
473
+ {
474
+ "label": candidate.label,
475
+ "slug": candidate.slug,
476
+ "id": candidate.id,
477
+ "description": candidate.description or "",
478
+ "aliases": ", ".join(candidate.aliases),
479
+ }
480
+ for candidate in candidates
481
+ ]
482
+ print_output(console, rows, "table" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
483
+
484
+
485
+ @app.command("options", context_settings=COMMON_CONTEXT_SETTINGS)
486
+ def options_command(ctx: typer.Context) -> None:
487
+ """List/search dynamic enum options.
488
+
489
+ Examples:
490
+ sv options list
491
+ sv options seogpt
492
+ sv options seogpt contenttype --search meta
493
+ """
494
+ try:
495
+ positionals, parsed_options = split_positionals_and_options(list(ctx.args))
496
+ runtime = runtime_from_ctx(ctx)
497
+ runtime = apply_runtime_overrides(runtime, parsed_options)
498
+ ctx.obj = runtime
499
+ search = parsed_options.pop("search", None)
500
+ if positionals and positionals[0] == "list":
501
+ show_options(ctx, None, None, search)
502
+ return
503
+ tool = positionals[0] if positionals else None
504
+ field = positionals[1] if len(positionals) > 1 else None
505
+ show_options(ctx, tool, field, search)
506
+ except Exception as exc: # noqa: BLE001
507
+ fail(exc)
508
+
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # Raw calls and task commands
512
+ # ---------------------------------------------------------------------------
513
+ @app.command("call")
514
+ def raw_call(
515
+ ctx: typer.Context,
516
+ tool: str = typer.Argument(..., help="Tool name or alias."),
517
+ json_payload: str | None = typer.Option(None, "--json", help="Raw JSON object payload."),
518
+ file_path: str | None = typer.Option(None, "--file", help="Read raw JSON object payload from file."),
519
+ stdin_flag: bool = typer.Option(False, "--stdin", help="Read raw JSON object payload from stdin."),
520
+ method: str = typer.Option("POST", "--method", help="HTTP method, usually POST."),
521
+ ) -> None:
522
+ try:
523
+ stdin_payload = sys.stdin.read() if stdin_flag else None
524
+ payload = load_json_payload(json_payload=json_payload, file_path=file_path, stdin_payload=stdin_payload)
525
+ runtime = runtime_from_ctx(ctx)
526
+ # Raw calls intentionally bypass fuzzy matching. Users can pass exact API payloads.
527
+ runtime.no_fuzzy = True
528
+ execute_tool(tool_name=tool, action=None, params={}, runtime=runtime, raw_payload=payload, method=method, console=console)
529
+ except Exception as exc: # noqa: BLE001
530
+ fail(exc)
531
+
532
+
533
+ task_app = typer.Typer(help="Check async task status/results.", no_args_is_help=True)
534
+
535
+
536
+ def task_call(ctx: typer.Context, task_id: str, action: str, tool: str | None) -> None:
537
+ runtime = runtime_from_ctx(ctx)
538
+ manager = DefinitionsManager(runtime.base_url)
539
+ canonical = manager.resolve_tool(get_task_tool(task_id, tool))
540
+ entry = manager.get_tool(canonical)
541
+ api_key = __import__("sv_cli.config", fromlist=["resolve_api_key"]).resolve_api_key(
542
+ cli_api_key=runtime.api_key,
543
+ profile=runtime.profile,
544
+ allow_prompt=not runtime.non_interactive,
545
+ non_interactive=runtime.non_interactive,
546
+ )
547
+ payload = status_payload(task_id) if action == "status" else result_payload(task_id)
548
+ data = APIClient(debug=runtime.debug, console=err_console).request_tool(
549
+ endpoint=str(entry.get("endpoint")), payload=payload, api_key=api_key
550
+ ).data
551
+ print_output(console, data, runtime.output_format, runtime.output)
552
+
553
+
554
+ @task_app.command("status")
555
+ def task_status(
556
+ ctx: typer.Context,
557
+ task_id: str = typer.Argument(...),
558
+ tool: str | None = typer.Option(None, "--tool", help="Tool that created the task, if not in local task cache."),
559
+ ) -> None:
560
+ try:
561
+ task_call(ctx, task_id, "status", tool)
562
+ except Exception as exc: # noqa: BLE001
563
+ fail(exc)
564
+
565
+
566
+ @task_app.command("result")
567
+ def task_result(
568
+ ctx: typer.Context,
569
+ task_id: str = typer.Argument(...),
570
+ tool: str | None = typer.Option(None, "--tool", help="Tool that created the task, if not in local task cache."),
571
+ ) -> None:
572
+ try:
573
+ task_call(ctx, task_id, "result", tool)
574
+ except Exception as exc: # noqa: BLE001
575
+ fail(exc)
576
+
577
+
578
+ app.add_typer(task_app, name="task")
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Tool command groups
583
+ # ---------------------------------------------------------------------------
584
+ def build_params(
585
+ *,
586
+ keyword: str | None,
587
+ keywords: str | None,
588
+ url: str | None,
589
+ url_a: str | None,
590
+ url_b: str | None,
591
+ brand: str | None,
592
+ type_value: str | None,
593
+ language: str | None,
594
+ engine: str | None,
595
+ country: str | None,
596
+ location: str | None,
597
+ text: str | None,
598
+ file_path: str | None,
599
+ stdin_flag: bool,
600
+ search: str | None,
601
+ query: str | None,
602
+ price: str | None,
603
+ series: str | None,
604
+ category: str | None,
605
+ outline: str | None,
606
+ theme: str | None,
607
+ background: str | None,
608
+ color: str | None,
609
+ size: str | None,
610
+ wait: bool,
611
+ timeout: int,
612
+ poll_interval: int,
613
+ no_progress: bool,
614
+ output_format: str | None,
615
+ output: str | None,
616
+ quiet: bool,
617
+ verbose: bool,
618
+ debug: bool,
619
+ strict: bool,
620
+ no_fuzzy: bool,
621
+ non_interactive: bool,
622
+ api_key: str | None,
623
+ base_url: str | None,
624
+ profile: str | None,
625
+ ) -> dict[str, Any]:
626
+ params: dict[str, Any] = {
627
+ "keyword": keyword,
628
+ "keywords": keywords,
629
+ "url": url,
630
+ "url_a": url_a,
631
+ "url_b": url_b,
632
+ "brand": brand,
633
+ "type": type_value,
634
+ "language": language,
635
+ "engine": engine,
636
+ "country": country,
637
+ "location": location,
638
+ "search": search,
639
+ "query": query,
640
+ "price": price,
641
+ "series": series,
642
+ "category": category,
643
+ "outline": outline,
644
+ "theme": theme,
645
+ "background": background,
646
+ "color": color,
647
+ "size": size,
648
+ "wait": wait,
649
+ "timeout": timeout,
650
+ "poll_interval": poll_interval,
651
+ "no_progress": no_progress,
652
+ "format": output_format,
653
+ "output": output,
654
+ "quiet": quiet,
655
+ "verbose": verbose,
656
+ "debug": debug,
657
+ "strict": strict,
658
+ "no_fuzzy": no_fuzzy,
659
+ "non_interactive": non_interactive,
660
+ "api_key": api_key,
661
+ "base_url": base_url,
662
+ "profile": profile,
663
+ }
664
+ try:
665
+ source = read_text_source(text=text, file_path=file_path, use_stdin=stdin_flag, field_name="text")
666
+ except ValueError as exc:
667
+ raise InvalidInputError(str(exc)) from exc
668
+ if source:
669
+ field, content = source
670
+ params[field] = content
671
+ return params
672
+
673
+
674
+ def make_action_command(tool: str, action: str):
675
+ def command(
676
+ ctx: typer.Context,
677
+ keyword: str | None = typer.Option(None, "--keyword", "--kw", help="Primary keyword."),
678
+ keywords: str | None = typer.Option(None, "--keywords", help="Comma-separated keywords or path to a keyword file."),
679
+ url: str | None = typer.Option(None, "--url", help="Target URL/domain."),
680
+ url_a: str | None = typer.Option(None, "--url-a", "--url1", help="First URL for comparison or URL 1."),
681
+ url_b: str | None = typer.Option(None, "--url-b", "--url2", help="Second URL for comparison or URL 2."),
682
+ brand: str | None = typer.Option(None, "--brand", help="Brand or company name."),
683
+ type_value: str | None = typer.Option(None, "--type", help="Type/content type/image type by ID, slug, label, or alias."),
684
+ language: str | None = typer.Option(None, "--language", help="Language by ID, slug, label, or alias."),
685
+ engine: str | None = typer.Option(None, "--engine", help="Engine/model by ID, slug, label, or alias."),
686
+ country: str | None = typer.Option(None, "--country", help="Country/market code where supported."),
687
+ location: str | None = typer.Option(None, "--location", help="Location/market where supported."),
688
+ text: str | None = typer.Option(None, "--text", help="Text input for transformer-style tools."),
689
+ file_path: str | None = typer.Option(None, "--file", help="Read text input from file."),
690
+ stdin_flag: bool = typer.Option(False, "--stdin", help="Read text input from stdin."),
691
+ search: str | None = typer.Option(None, "--search", "--searchterm", help="Search term for marketplace/service-style tools."),
692
+ query: str | None = typer.Option(None, "--query", help="Query/search term where supported."),
693
+ price: str | None = typer.Option(None, "--price", help="Price ceiling or price filter where supported."),
694
+ series: str | None = typer.Option(None, "--series", help="Service series filter where supported."),
695
+ category: str | None = typer.Option(None, "--category", help="Service category filter where supported."),
696
+ outline: str | None = typer.Option(None, "--outline", help="Outline text or path to outline file."),
697
+ theme: str | None = typer.Option(None, "--theme", help="Image theme by ID, slug, label, or alias."),
698
+ background: str | None = typer.Option(None, "--background", help="Image background by ID, slug, label, or alias."),
699
+ color: str | None = typer.Option(None, "--color", help="Image color/palette by ID, slug, label, or alias."),
700
+ size: str | None = typer.Option(None, "--size", help="Image size by ID, slug, label, or alias."),
701
+ wait: bool = typer.Option(False, "--wait", help="Wait for async task completion."),
702
+ timeout: int = typer.Option(600, "--timeout", help="Task wait timeout in seconds."),
703
+ poll_interval: int = typer.Option(5, "--poll-interval", help="Task poll interval in seconds."),
704
+ no_progress: bool = typer.Option(False, "--no-progress", help="Disable progress display."),
705
+ output_format: str | None = typer.Option(None, "--format", help="pretty|json|table|csv|markdown|text"),
706
+ output: str | None = typer.Option(None, "--output", help="Write output to a file."),
707
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress normal output."),
708
+ verbose: bool = typer.Option(False, "--verbose", help="Verbose output."),
709
+ debug: bool = typer.Option(False, "--debug", help="Debug output with secrets masked."),
710
+ strict: bool = typer.Option(False, "--strict", help="Strict enum matching."),
711
+ no_fuzzy: bool = typer.Option(False, "--no-fuzzy", help="Disable fuzzy enum matching."),
712
+ non_interactive: bool = typer.Option(False, "--non-interactive", help="Disable prompts."),
713
+ api_key: str | None = typer.Option(None, "--api-key", help="API key override."),
714
+ base_url: str | None = typer.Option(None, "--base-url", help="Base URL override."),
715
+ profile: str | None = typer.Option(None, "--profile", help="Profile override."),
716
+ ) -> None:
717
+ params = build_params(
718
+ keyword=keyword,
719
+ keywords=keywords,
720
+ url=url,
721
+ url_a=url_a,
722
+ url_b=url_b,
723
+ brand=brand,
724
+ type_value=type_value,
725
+ language=language,
726
+ engine=engine,
727
+ country=country,
728
+ location=location,
729
+ text=text,
730
+ file_path=file_path,
731
+ stdin_flag=stdin_flag,
732
+ search=search,
733
+ query=query,
734
+ price=price,
735
+ series=series,
736
+ category=category,
737
+ outline=outline,
738
+ theme=theme,
739
+ background=background,
740
+ color=color,
741
+ size=size,
742
+ wait=wait,
743
+ timeout=timeout,
744
+ poll_interval=poll_interval,
745
+ no_progress=no_progress,
746
+ output_format=output_format,
747
+ output=output,
748
+ quiet=quiet,
749
+ verbose=verbose,
750
+ debug=debug,
751
+ strict=strict,
752
+ no_fuzzy=no_fuzzy,
753
+ non_interactive=non_interactive,
754
+ api_key=api_key,
755
+ base_url=base_url,
756
+ profile=profile,
757
+ )
758
+ run_tool_action(ctx, tool=tool, action=action, params=params)
759
+
760
+ command.__name__ = f"{tool.replace('-', '_')}_{action.replace('-', '_')}"
761
+ return command
762
+
763
+
764
+ def make_option_alias_command(tool: str, field_aliases: tuple[str, ...]):
765
+ def command(
766
+ ctx: typer.Context,
767
+ search: str | None = typer.Option(None, "--search", help="Filter options."),
768
+ output_format: str | None = typer.Option(None, "--format", help="Output format."),
769
+ output: str | None = typer.Option(None, "--output", help="Write output to file."),
770
+ ) -> None:
771
+ try:
772
+ runtime = runtime_from_ctx(ctx)
773
+ overrides: dict[str, Any] = {"format": output_format, "output": output}
774
+ runtime = apply_runtime_overrides(runtime, overrides)
775
+ ctx.obj = runtime
776
+ show_options(ctx, tool, field_aliases[0], search)
777
+ except Exception as exc: # noqa: BLE001
778
+ fail(exc)
779
+
780
+ command.__name__ = f"{tool.replace('-', '_')}_{field_aliases[0]}_options"
781
+ return command
782
+
783
+
784
+ def make_tool_app(adapter: ToolAdapter) -> typer.Typer:
785
+ tool_app = typer.Typer(
786
+ help=f"Commands for {adapter.command} ({adapter.canonical}).",
787
+ invoke_without_command=True,
788
+ context_settings=COMMON_CONTEXT_SETTINGS,
789
+ )
790
+
791
+ @tool_app.callback(invoke_without_command=True, context_settings=COMMON_CONTEXT_SETTINGS)
792
+ def tool_callback(ctx: typer.Context) -> None:
793
+ if ctx.invoked_subcommand is not None:
794
+ return
795
+ try:
796
+ params = parse_key_value_args(ctx.args)
797
+ runtime = runtime_from_ctx(ctx)
798
+ runtime = apply_runtime_overrides(runtime, params)
799
+ wait_options = pop_wait_options(params)
800
+ execute_tool(
801
+ tool_name=adapter.canonical,
802
+ action=adapter.default_action,
803
+ params=params,
804
+ runtime=runtime,
805
+ wait_options=wait_options,
806
+ console=console,
807
+ )
808
+ except Exception as exc: # noqa: BLE001
809
+ fail(exc)
810
+
811
+ for action in adapter.actions:
812
+ if action == "raw":
813
+ continue
814
+ tool_app.command(action, context_settings=COMMON_CONTEXT_SETTINGS)(make_action_command(adapter.canonical, action))
815
+
816
+ @tool_app.command("raw")
817
+ def raw(
818
+ ctx: typer.Context,
819
+ json_payload: str | None = typer.Option(None, "--json", help="Raw JSON payload."),
820
+ file_path: str | None = typer.Option(None, "--file", help="Read raw JSON from file."),
821
+ stdin_flag: bool = typer.Option(False, "--stdin", help="Read raw JSON from stdin."),
822
+ method: str = typer.Option("POST", "--method", help="HTTP method."),
823
+ ) -> None:
824
+ try:
825
+ stdin_payload = sys.stdin.read() if stdin_flag else None
826
+ payload = load_json_payload(json_payload=json_payload, file_path=file_path, stdin_payload=stdin_payload)
827
+ runtime = runtime_from_ctx(ctx)
828
+ runtime.no_fuzzy = True
829
+ execute_tool(
830
+ tool_name=adapter.canonical,
831
+ action=None,
832
+ params={},
833
+ runtime=runtime,
834
+ raw_payload=payload,
835
+ method=method,
836
+ console=console,
837
+ )
838
+ except Exception as exc: # noqa: BLE001
839
+ fail(exc)
840
+
841
+ for command_name, fields in adapter.option_aliases.items():
842
+ tool_app.command(command_name)(make_option_alias_command(adapter.canonical, fields))
843
+
844
+ return tool_app
845
+
846
+
847
+ registered_names: set[str] = set()
848
+ for adapter in TOOL_ADAPTERS.values():
849
+ group = make_tool_app(adapter)
850
+ for name in (adapter.command, *adapter.aliases):
851
+ if name not in registered_names:
852
+ app.add_typer(group, name=name)
853
+ registered_names.add(name)
854
+
855
+
856
+ # ---------------------------------------------------------------------------
857
+ # Beginner-friendly presets
858
+ # ---------------------------------------------------------------------------
859
+ @app.command("meta", context_settings=COMMON_CONTEXT_SETTINGS)
860
+ def preset_meta(
861
+ ctx: typer.Context,
862
+ keyword: str = typer.Option(..., "--keyword", "--kw"),
863
+ url: str | None = typer.Option(None, "--url"),
864
+ output_format: str | None = typer.Option(None, "--format"),
865
+ output: str | None = typer.Option(None, "--output"),
866
+ ) -> None:
867
+ params = {"type": "meta-description", "keyword": keyword, "url": url, "format": output_format, "output": output}
868
+ run_tool_action(ctx, tool="seogpt", action="generate", params=params)
869
+
870
+
871
+ @app.command("title", context_settings=COMMON_CONTEXT_SETTINGS)
872
+ def preset_title(
873
+ ctx: typer.Context,
874
+ keyword: str = typer.Option(..., "--keyword", "--kw"),
875
+ url: str | None = typer.Option(None, "--url"),
876
+ brand: str | None = typer.Option(None, "--brand"),
877
+ output_format: str | None = typer.Option(None, "--format"),
878
+ output: str | None = typer.Option(None, "--output"),
879
+ ) -> None:
880
+ params = {
881
+ "type": "page-title",
882
+ "keyword": keyword,
883
+ "url": url,
884
+ "brand": brand,
885
+ "format": output_format,
886
+ "output": output,
887
+ }
888
+ run_tool_action(ctx, tool="seogpt", action="generate", params=params)
889
+
890
+
891
+ @app.command("registry")
892
+ def registry(ctx: typer.Context) -> None:
893
+ """Show local command adapters used on top of live definitions."""
894
+
895
+ try:
896
+ runtime = runtime_from_ctx(ctx)
897
+ data = {name: adapter_as_dict(adapter) for name, adapter in TOOL_ADAPTERS.items()}
898
+ data["command_aliases"] = all_command_names()
899
+ print_output(console, data, "json" if runtime.output_format == "pretty" else runtime.output_format, runtime.output)
900
+ except Exception as exc: # noqa: BLE001
901
+ fail(exc)
902
+
903
+
904
+ if __name__ == "__main__": # pragma: no cover
905
+ app()