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/tasks.py ADDED
@@ -0,0 +1,168 @@
1
+ """Async task helpers.
2
+
3
+ The public API root currently advertises tool endpoints, not a separate task
4
+ endpoint. This module therefore keeps task handling generic: task-creating tool
5
+ commands record the tool that produced a task ID, then task status/result calls
6
+ use that tool endpoint with status/result actions unless a future definition
7
+ states otherwise.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from rich.console import Console
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
18
+
19
+ from .api_client import APIClient
20
+ from .definitions import DefinitionsManager
21
+ from .errors import ConfigError, TimeoutError
22
+ from .utils import app_dir, read_json_file, write_json_file
23
+
24
+ TASK_ID_KEYS = ("task_id", "taskId", "task", "id", "job_id", "jobId")
25
+ STATUS_KEYS = ("status", "state", "task_status")
26
+ DONE_STATES = {"done", "complete", "completed", "success", "succeeded", "finished", "ready"}
27
+ ERROR_STATES = {"error", "failed", "failure", "cancelled", "canceled"}
28
+
29
+
30
+ def tasks_path() -> Path:
31
+ return app_dir() / "tasks.json"
32
+
33
+
34
+ def load_tasks() -> dict[str, Any]:
35
+ return read_json_file(tasks_path(), {}) or {}
36
+
37
+
38
+ def save_task(task_id: str, tool: str, endpoint: str | None = None) -> None:
39
+ tasks = load_tasks()
40
+ tasks[task_id] = {"tool": tool, "endpoint": endpoint, "created_at": int(time.time())}
41
+ write_json_file(tasks_path(), tasks)
42
+
43
+
44
+ def get_task_tool(task_id: str, tool: str | None = None) -> str:
45
+ if tool:
46
+ return tool
47
+ tasks = load_tasks()
48
+ entry = tasks.get(task_id)
49
+ if entry and entry.get("tool"):
50
+ return str(entry["tool"])
51
+ raise ConfigError(
52
+ f'No local tool mapping found for task "{task_id}". Re-run with --tool, for example: '
53
+ f"sv task status {task_id} --tool seogpt2"
54
+ )
55
+
56
+
57
+ def extract_task_id(data: Any) -> str | None:
58
+ if isinstance(data, dict):
59
+ for key in TASK_ID_KEYS:
60
+ value = data.get(key)
61
+ if value:
62
+ return str(value)
63
+ for value in data.values():
64
+ found = extract_task_id(value)
65
+ if found:
66
+ return found
67
+ if isinstance(data, list):
68
+ for item in data:
69
+ found = extract_task_id(item)
70
+ if found:
71
+ return found
72
+ return None
73
+
74
+
75
+ def extract_status(data: Any) -> str | None:
76
+ if isinstance(data, dict):
77
+ for key in STATUS_KEYS:
78
+ value = data.get(key)
79
+ if value:
80
+ return str(value).lower()
81
+ for value in data.values():
82
+ found = extract_status(value)
83
+ if found:
84
+ return found
85
+ return None
86
+
87
+
88
+ def has_result(data: Any) -> bool:
89
+ # "data" is deliberately excluded: it's SV API's universal response envelope key,
90
+ # present on every response including in-progress status checks, so treating it as
91
+ # a completion signal made poll_once() below declare victory on the very first poll
92
+ # regardless of actual task status.
93
+ if isinstance(data, dict):
94
+ return any(key in data for key in ("result", "results", "output", "content"))
95
+ return data is not None
96
+
97
+
98
+ def status_payload(task_id: str) -> dict[str, Any]:
99
+ return {"action": "getTaskStatus", "task_id": task_id}
100
+
101
+
102
+ def result_payload(task_id: str) -> dict[str, Any]:
103
+ return {"action": "getResult", "task_id": task_id}
104
+
105
+
106
+ def wait_for_task(
107
+ *,
108
+ task_id: str,
109
+ tool: str,
110
+ api_key: str,
111
+ definitions: DefinitionsManager,
112
+ client: APIClient,
113
+ timeout_seconds: int = 600,
114
+ poll_interval: int = 5,
115
+ no_progress: bool = False,
116
+ console: Console | None = None,
117
+ ) -> Any:
118
+ console = console or Console(stderr=True)
119
+ entry = definitions.get_tool(tool)
120
+ endpoint = entry.get("endpoint")
121
+ if not endpoint:
122
+ raise ConfigError(f'Tool "{tool}" does not have an endpoint in definitions cache.')
123
+
124
+ deadline = time.monotonic() + timeout_seconds
125
+
126
+ def poll_once() -> Any:
127
+ status_response = client.request_tool(
128
+ endpoint=str(endpoint), payload=status_payload(task_id), api_key=api_key, timeout=poll_interval + 30
129
+ ).data
130
+ status = extract_status(status_response)
131
+ if status in ERROR_STATES:
132
+ return status_response
133
+ if status in DONE_STATES or has_result(status_response):
134
+ # Some APIs return the result directly from status. If not, ask for result.
135
+ if any(key in status_response for key in ("result", "results", "output", "content")):
136
+ return status_response
137
+ return client.request_tool(endpoint=str(endpoint), payload=result_payload(task_id), api_key=api_key).data
138
+ return None
139
+
140
+ if no_progress:
141
+ while time.monotonic() < deadline:
142
+ result = poll_once()
143
+ if result is not None:
144
+ return result
145
+ time.sleep(poll_interval)
146
+ else:
147
+ with Progress(
148
+ SpinnerColumn(),
149
+ TextColumn("Polling task {task.fields[task_id]}"),
150
+ TimeElapsedColumn(),
151
+ console=console,
152
+ transient=True,
153
+ ) as progress:
154
+ progress_task = progress.add_task("poll", task_id=task_id, total=None)
155
+ while time.monotonic() < deadline:
156
+ result = poll_once()
157
+ if result is not None:
158
+ progress.update(progress_task, completed=1)
159
+ return result
160
+ time.sleep(poll_interval)
161
+
162
+ raise TimeoutError(
163
+ "The task is still running.\n"
164
+ f"Task ID:\n{task_id}\n"
165
+ "Check later with:\n"
166
+ f"sv task status {task_id}\n"
167
+ f"sv task result {task_id}"
168
+ )
sv_cli/utils.py ADDED
@@ -0,0 +1,204 @@
1
+ """Shared utility functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ from collections.abc import Iterable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ _SECRET_KEYS = {"k", "key", "api_key", "apikey", "apiKey", "token", "authorization", "Authorization"}
14
+
15
+
16
+ def app_dir() -> Path:
17
+ """Return the CLI state directory, overridable for tests.
18
+
19
+ SV_HOME is the current override. SEOVENDOR_HOME is accepted as a legacy
20
+ fallback so existing automation can migrate without losing local state.
21
+ """
22
+
23
+ override = os.environ.get("SV_HOME") or os.environ.get("SEOVENDOR_HOME")
24
+ return Path(override).expanduser() if override else Path.home() / ".sv"
25
+
26
+
27
+ def legacy_app_dir() -> Path:
28
+ """Return the pre-brand-change state directory."""
29
+
30
+ return Path.home() / ".seovendor"
31
+
32
+
33
+ def ensure_parent(path: Path) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+
36
+
37
+ def read_json_file(path: Path, default: Any = None) -> Any:
38
+ if not path.exists():
39
+ return default
40
+ try:
41
+ return json.loads(path.read_text(encoding="utf-8"))
42
+ except json.JSONDecodeError as exc:
43
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
44
+
45
+
46
+ def write_json_file(path: Path, data: Any) -> None:
47
+ ensure_parent(path)
48
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
49
+ try:
50
+ path.chmod(0o600)
51
+ except OSError:
52
+ pass
53
+
54
+
55
+ def mask_secret(value: Any, visible: int = 4) -> Any:
56
+ if value is None:
57
+ return None
58
+ text = str(value)
59
+ if len(text) <= visible:
60
+ return "***masked***"
61
+ return f"***masked***{text[-visible:]}"
62
+
63
+
64
+ def mask_mapping(data: Any) -> Any:
65
+ """Return a deep-copy-like structure with common secret fields masked."""
66
+
67
+ if isinstance(data, dict):
68
+ masked: dict[str, Any] = {}
69
+ for key, value in data.items():
70
+ if key in _SECRET_KEYS or key.lower() in _SECRET_KEYS:
71
+ masked[key] = mask_secret(value)
72
+ else:
73
+ masked[key] = mask_mapping(value)
74
+ return masked
75
+ if isinstance(data, list):
76
+ return [mask_mapping(item) for item in data]
77
+ return data
78
+
79
+
80
+ def normalize_text(value: Any) -> str:
81
+ """Normalize text according to the spec's enum matching rules."""
82
+
83
+ text = str(value).lower().strip()
84
+ text = text.replace("_", " ").replace("-", " ")
85
+ text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE)
86
+ text = re.sub(r"\s+", " ", text).strip()
87
+ return text
88
+
89
+
90
+ def compact_text(value: Any) -> str:
91
+ return normalize_text(value).replace(" ", "")
92
+
93
+
94
+ def slugify(value: Any) -> str:
95
+ text = normalize_text(value)
96
+ return re.sub(r"\s+", "-", text).strip("-")
97
+
98
+
99
+ def parse_key_value_args(args: Iterable[str]) -> dict[str, Any]:
100
+ """Parse unknown CLI args such as --foo bar, --foo=bar, and --flag.
101
+
102
+ Hyphenated option names are converted to underscores because the API definitions
103
+ overwhelmingly use compact/snake names, while the command line should remain human-friendly.
104
+ """
105
+
106
+ result: dict[str, Any] = {}
107
+ args = list(args)
108
+ i = 0
109
+ while i < len(args):
110
+ token = args[i]
111
+ if not token.startswith("--"):
112
+ i += 1
113
+ continue
114
+ key = token[2:]
115
+ value: Any = True
116
+ if "=" in key:
117
+ key, value = key.split("=", 1)
118
+ elif i + 1 < len(args) and not args[i + 1].startswith("--"):
119
+ value = args[i + 1]
120
+ i += 1
121
+ key = key.strip().replace("-", "_")
122
+ if key:
123
+ if key in result:
124
+ existing = result[key]
125
+ if isinstance(existing, list):
126
+ existing.append(value)
127
+ else:
128
+ result[key] = [existing, value]
129
+ else:
130
+ result[key] = value
131
+ i += 1
132
+ return result
133
+
134
+
135
+ def read_text_source(
136
+ *,
137
+ text: str | None = None,
138
+ file_path: str | None = None,
139
+ use_stdin: bool = False,
140
+ field_name: str = "text",
141
+ ) -> tuple[str, str] | None:
142
+ """Read text from explicit text, file, or stdin.
143
+
144
+ Returns (field_name, content) when data is present.
145
+ """
146
+
147
+ supplied = [text is not None, file_path is not None, use_stdin]
148
+ if sum(bool(x) for x in supplied) > 1:
149
+ raise ValueError(f"Use only one of --{field_name}, --file, or --stdin")
150
+ if text is not None:
151
+ return field_name, text
152
+ if file_path is not None:
153
+ return field_name, Path(file_path).expanduser().read_text(encoding="utf-8")
154
+ if use_stdin:
155
+ return field_name, sys.stdin.read()
156
+ return None
157
+
158
+
159
+ def maybe_read_file_value(value: str | None) -> str | None:
160
+ """If value names an existing file, return its contents; otherwise return the value."""
161
+
162
+ if not value:
163
+ return value
164
+ path = Path(value).expanduser()
165
+ if path.exists() and path.is_file():
166
+ return path.read_text(encoding="utf-8")
167
+ return value
168
+
169
+
170
+ def coerce_jsonish(value: str) -> Any:
171
+ """Best-effort conversion for extra params."""
172
+
173
+ lowered = value.lower()
174
+ if lowered == "true":
175
+ return True
176
+ if lowered == "false":
177
+ return False
178
+ if lowered == "null":
179
+ return None
180
+ try:
181
+ if re.fullmatch(r"-?\d+", value):
182
+ return int(value)
183
+ if re.fullmatch(r"-?\d+\.\d+", value):
184
+ return float(value)
185
+ except ValueError:
186
+ pass
187
+ if value.startswith("{") or value.startswith("["):
188
+ try:
189
+ return json.loads(value)
190
+ except json.JSONDecodeError:
191
+ return value
192
+ return value
193
+
194
+
195
+ def coerce_mapping_values(data: dict[str, Any]) -> dict[str, Any]:
196
+ coerced: dict[str, Any] = {}
197
+ for key, value in data.items():
198
+ if isinstance(value, list):
199
+ coerced[key] = [coerce_jsonish(v) if isinstance(v, str) else v for v in value]
200
+ elif isinstance(value, str):
201
+ coerced[key] = coerce_jsonish(value)
202
+ else:
203
+ coerced[key] = value
204
+ return coerced
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.4
2
+ Name: sv-cli
3
+ Version: 0.3.0
4
+ Summary: Open-source, definition-driven command-line client for SV AI API tools.
5
+ Project-URL: Homepage, https://github.com/seovendorco/sv-cli
6
+ Project-URL: Issues, https://github.com/seovendorco/sv-cli/issues
7
+ Author: SV CLI Contributors
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,automation,cli,seo,sv
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.25
24
+ Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: rich>=13.6
26
+ Requires-Dist: typer>=0.9
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.0; extra == 'dev'
29
+ Requires-Dist: mypy>=1.8; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
31
+ Requires-Dist: pytest>=7.4; extra == 'dev'
32
+ Requires-Dist: ruff>=0.4; extra == 'dev'
33
+ Requires-Dist: twine>=4.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ ```
37
+ ███████╗██╗ ██╗ ██████╗██╗ ██╗
38
+ ██╔════╝██║ ██║ ██╔════╝██║ ██║
39
+ ███████╗██║ ██║ ██║ ██║ ██║
40
+ ╚════██║╚██╗ ██╔╝ ██║ ██║ ██║
41
+ ███████║ ╚████╔╝ ╚██████╗███████╗██║
42
+ ╚══════╝ ╚═══╝ ╚═════╝╚══════╝╚═╝
43
+
44
+ Agentic SEO & GEO from the command line
45
+ ```
46
+ # SV CLI
47
+
48
+ SV CLI is an open-source command-line client for agentic SEO and GEO workflows using the SV API (SEO VENDOR API). It is designed as a definition-driven resolver layer: humans can use friendly commands, slugs, aliases, and presets, while scripts and AI agents can use strict enum IDs and raw JSON calls.
49
+
50
+ The CLI discovers available tools from the API root and fetches each tool's definitions endpoint at runtime. Definitions are cached locally for speed, refreshed automatically after 24 hours by default, and can be refreshed or cleared manually.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install sv-cli
56
+ ```
57
+
58
+ For local development:
59
+
60
+ ```bash
61
+ git clone https://github.com/seovendor/sv-cli.git
62
+ cd sv-cli
63
+ python -m venv .venv
64
+ source .venv/bin/activate
65
+ pip install -e '.[dev]'
66
+ sv --help
67
+ ```
68
+
69
+ ## API key setup
70
+
71
+ An API key is required for API calls. Resolution order:
72
+
73
+ 1. `--api-key` flag
74
+ 2. `SV_API_KEY` environment variable
75
+ 3. Stored profile config from `sv auth set`
76
+ 4. Interactive prompt when allowed
77
+
78
+ `SV_API_KEY` is the current environment variable. `SEOVENDOR_API_KEY` is still accepted as a legacy fallback during migration.
79
+
80
+ Recommended setup:
81
+
82
+ ```bash
83
+ sv auth set
84
+ sv auth status
85
+ ```
86
+
87
+ CI setup:
88
+
89
+ ```bash
90
+ export SV_API_KEY="your-key-here"
91
+ sv seogpt generate --type 18 --keyword "white label seo" --strict --format json --non-interactive
92
+ ```
93
+
94
+ Avoid passing real keys with `--api-key` in shared shells because shell history may store the value. Debug output masks keys.
95
+
96
+ ### Brand migration notes
97
+
98
+ The package, repository, executable, Python package, and local state directory now use the SV brand:
99
+
100
+ - PyPI package: `sv-cli`
101
+ - Executable: `sv`
102
+ - Python package: `sv_cli`
103
+ - Default local state directory: `~/.sv`
104
+ - Preferred environment variables: `SV_API_KEY` and `SV_HOME`
105
+
106
+ For migration safety, `SEOVENDOR_API_KEY`, `SEOVENDOR_HOME`, and an existing `~/.seovendor/config.json` are still accepted as fallbacks. New saves are written to `~/.sv`.
107
+
108
+ ## Dynamic definitions cache
109
+
110
+ ```bash
111
+ sv definitions refresh
112
+ sv definitions list
113
+ sv definitions show seogpt
114
+ sv definitions clear
115
+ ```
116
+
117
+ Cache location:
118
+
119
+ ```text
120
+ ~/.sv/cache/definitions.json
121
+ ```
122
+
123
+ The old `~/.seovendor/config.json` file is read as a migration fallback when `~/.sv/config.json` does not exist. New writes go to `~/.sv`.
124
+
125
+ Default behavior:
126
+
127
+ - Use local cache when present.
128
+ - Refresh automatically if the cache is older than 24 hours.
129
+ - Use stale cache with a warning if refresh fails.
130
+ - Error clearly if no cache exists and definitions cannot be fetched.
131
+
132
+ ## Quick start
133
+
134
+ ```bash
135
+ sv keywords research --keyword "white label seo"
136
+ sv seogpt generate --type 18 --keyword "white label seo" --url https://example.com
137
+ sv geo-audit create-task --url https://example.com --keyword "seo agency,white label seo" --wait
138
+ sv image generate --keyword "white label seo" --type blog-header-image
139
+ sv top-competitors analyze --keyword "white label seo"
140
+ sv marketplace-services search --search "seo audit" --price 500 --category SEO
141
+ sv content-quality analyze --keyword "white label seo" --url https://example.com
142
+ ```
143
+
144
+ Presets (shorthand commands for common seogpt operations):
145
+
146
+ ```bash
147
+ sv meta --keyword "white label seo" --url https://example.com
148
+ sv title --keyword "white label seo" --url https://example.com
149
+ ```
150
+
151
+ > **Note:** `sv keywords`, `sv audit`, and `sv image` are group aliases, not standalone commands. They require an action: `sv keywords research --keyword "..."`, `sv audit create-task --url "..."`, `sv image generate --keyword "..."`.
152
+
153
+ ## Supported tools
154
+
155
+ | Friendly command | Aliases | API tool key |
156
+ | --- | --- | --- |
157
+ | `better-keywords` | `keywords` | `better-keywords` |
158
+ | `content-transformer` | `transform` | `content-transformer` |
159
+ | `core-analysis` | `core` | `core-analysis` |
160
+ | `geo-audit` | `geogpt-audit`, `audit` | `geogptaudit` |
161
+ | `insight-igniter` | `insights` | `insight-igniter` |
162
+ | `preliminary-audit` | `prelim-audit` | `preliminaryaudit` |
163
+ | `ranklens` | | `ranklens` |
164
+ | `seo-image` | `image` | `seo-image` |
165
+ | `seogpt` | `seo-gpt` | `seogpt` |
166
+ | `seogpt2` | `seo-gpt2` | `seogpt2` |
167
+ | `seogpt-compare` | `compare` | `seogptcompare` |
168
+ | `seo-mapping` | `mapping` | `seogptmapping` |
169
+ | `topical-authority` | `topical` | `topical-authority` |
170
+ | `top-competitors` | `competitors` | `top-competitors` |
171
+ | `marketplace-services` | `marketplace`, `services` | `marketplace-services` |
172
+ | `content-quality` | `quality`, `hcu-quality`, `eeat-quality` | `content-quality` |
173
+
174
+ The canonical API tool keys are discovered from the live API root; local aliases are only a human-friendly layer. For newly released tools, the CLI can also use adapter-provided endpoint hints until the API root advertises those tools. Root-discovered metadata still takes precedence.
175
+
176
+ ## Enum resolution
177
+
178
+ For enum-heavy fields such as content type, language, engine, image type, theme, background, color, and size, the CLI resolves values in this order:
179
+
180
+ 1. Numeric ID exact match
181
+ 2. Exact slug match
182
+ 3. Exact canonical API value
183
+ 4. Exact alias match
184
+ 5. Exact label match
185
+ 6. Normalized exact match
186
+ 7. Prefix match
187
+ 8. Contains match
188
+ 9. Fuzzy match with safe thresholds
189
+
190
+ Examples that all resolve to Meta Description (id 18):
191
+
192
+ ```bash
193
+ --type 18
194
+ --type meta-description
195
+ --type "Meta Description"
196
+ --type "meta desc"
197
+ ```
198
+
199
+ > **Note:** Fuzzy matching is enabled by default. Use `--strict --no-fuzzy` in scripts to require exact ID or slug and avoid unintended matches.
200
+
201
+ Agent-safe mode:
202
+
203
+ ```bash
204
+ sv seogpt generate --type 18 --keyword "white label seo" --strict --no-fuzzy --non-interactive --format json
205
+ ```
206
+
207
+ ## Options discovery
208
+
209
+ ```bash
210
+ sv options list
211
+ sv options seogpt
212
+ sv options seogpt type --search meta
213
+ sv options seogpt contenttype --search meta
214
+ sv seogpt types --search meta
215
+ sv seogpt languages
216
+ sv seogpt engines
217
+ sv image themes --search wild
218
+ sv image types --search blog
219
+ ```
220
+
221
+ ## Raw API calls
222
+
223
+ Raw calls use live definitions only to find the selected tool endpoint. The payload is otherwise passed through unchanged except that `k` is injected when no API-key field already exists.
224
+
225
+ ```bash
226
+ sv --format json call seogpt --json '{"action":"generate","kw":"white label seo","type":18}'
227
+ sv --format json call seogpt --file payload.json
228
+ cat payload.json | sv --format json call seogpt --stdin
229
+ ```
230
+
231
+ The per-tool `raw` sub-command works the same way:
232
+
233
+ ```bash
234
+ sv --format json seogpt raw --json '{"action":"generate","kw":"white label seo","type":18}'
235
+ ```
236
+
237
+ > **Note:** `--format` must be placed before the tool name for both `sv call` and `sv TOOL raw` — it is a global flag owned by the root `sv` command, not by `call` or `raw`.
238
+
239
+ ## Async task handling
240
+
241
+ For tools that return task IDs:
242
+
243
+ ```bash
244
+ sv geo-audit create-task --url https://example.com --keyword "seo agency,white label seo" --wait
245
+ sv task status TASK_ID --tool geo-audit
246
+ sv task result TASK_ID --tool geo-audit
247
+ ```
248
+
249
+ `seogpt2` is another async tool. Its required field is `Topic` (a title or subject), mapped via `--keyword`:
250
+
251
+ ```bash
252
+ sv seogpt2 create-task --keyword "White Label SEO for Agencies" --type on-page-blog-article --wait
253
+ ```
254
+
255
+ See available types with `sv seogpt2 types`, lengths with `sv seogpt2 lengths`, engines with `sv seogpt2 engines`.
256
+
257
+ Manual 3-step flow (without `--wait`):
258
+
259
+ ```bash
260
+ sv geo-audit create-task --url https://example.com --keyword "seo agency,white label seo"
261
+ sv geo-audit get-task-status --task_id TASK_ID
262
+ sv geo-audit get-result --task_id TASK_ID
263
+ ```
264
+
265
+ > **Note:** `--format` is not available on `sv task status` or `sv task result` directly. Place it before `task` as a global flag:
266
+ >
267
+ > ```bash
268
+ > sv --format json task status TASK_ID
269
+ > sv --format json task result TASK_ID
270
+ > ```
271
+
272
+ If a task is created through the CLI, its tool mapping is stored in `~/.sv/tasks.json`, so `--tool` is usually optional later.
273
+
274
+ ## Output formats
275
+
276
+ ```bash
277
+ --format pretty # default rich terminal output
278
+ --format json # raw JSON (always works for any response)
279
+ --format table # column table (list responses only)
280
+ --format csv # CSV (list responses only)
281
+ --format markdown # Markdown
282
+ --format text # plain text (content/article responses)
283
+ ```
284
+
285
+ Which format to use:
286
+
287
+ | Response type | Recommended formats |
288
+ |---|---|
289
+ | List results (keywords, competitors, services) | `table`, `csv` |
290
+ | Content / articles / generated text | `text`, `markdown` |
291
+ | Audit / analysis / nested data | `json`, `markdown` |
292
+ | Async task ID responses | `pretty` (default) |
293
+ | Any response | `json` (always safe) |
294
+
295
+ **Placement:** For tool commands (`sv seogpt generate`, `sv keywords research`, etc.), `--format` can go anywhere. For `sv call`, `sv options`, and `sv task`, `--format` must come before the subcommand:
296
+
297
+ ```bash
298
+ sv seogpt generate --keyword "white label seo" --type 18 --format json # works
299
+ sv --format json call seogpt --json '{"action":"generate","kw":"..."}' # must be before call
300
+ ```
301
+
302
+ Examples:
303
+
304
+ ```bash
305
+ sv keywords research --keyword "white label seo" --format csv --output keywords.csv
306
+ sv geo-audit create-task --url https://example.com --keyword "seo agency,white label seo" --wait --format markdown --output audit.md
307
+ ```
308
+
309
+ ## Profiles
310
+
311
+ ```bash
312
+ sv profile create agency-a
313
+ sv --profile agency-a auth set
314
+ sv profile use agency-a
315
+ sv profile list
316
+ sv profile delete agency-a
317
+ ```
318
+
319
+ Profile config is stored in `~/.sv/config.json`, not in project folders.
320
+
321
+ ## Development
322
+
323
+ ```bash
324
+ pip install -e '.[dev]'
325
+ pytest
326
+ ruff check .
327
+ python -m build
328
+ ```
329
+
330
+ Live tests should be opt-in only:
331
+
332
+ ```bash
333
+ SV_API_KEY=... RUN_LIVE_TESTS=1 pytest -m live
334
+ ```
335
+
336
+ ## Security
337
+
338
+ Never commit API keys, `.env`, or `~/.sv/config.json`. The repository `.gitignore` excludes common secret/config files. Report vulnerabilities using the process in `SECURITY.md`.