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.
- sv_cli/__init__.py +3 -0
- sv_cli/adapters.py +323 -0
- sv_cli/api_client.py +82 -0
- sv_cli/commands/__init__.py +0 -0
- sv_cli/commands/auth.py +4 -0
- sv_cli/commands/better_keywords.py +4 -0
- sv_cli/commands/call.py +4 -0
- sv_cli/commands/config.py +4 -0
- sv_cli/commands/content_quality.py +5 -0
- sv_cli/commands/content_transformer.py +4 -0
- sv_cli/commands/core_analysis.py +4 -0
- sv_cli/commands/definitions.py +4 -0
- sv_cli/commands/geo_audit.py +4 -0
- sv_cli/commands/insight_igniter.py +4 -0
- sv_cli/commands/marketplace_services.py +5 -0
- sv_cli/commands/options.py +4 -0
- sv_cli/commands/preliminary_audit.py +4 -0
- sv_cli/commands/ranklens.py +4 -0
- sv_cli/commands/seo_image.py +4 -0
- sv_cli/commands/seo_mapping.py +4 -0
- sv_cli/commands/seogpt.py +4 -0
- sv_cli/commands/seogpt2.py +4 -0
- sv_cli/commands/seogpt_compare.py +4 -0
- sv_cli/commands/top_competitors.py +5 -0
- sv_cli/commands/topical_authority.py +4 -0
- sv_cli/config.py +216 -0
- sv_cli/definitions.py +283 -0
- sv_cli/errors.py +55 -0
- sv_cli/executor.py +188 -0
- sv_cli/formatter.py +227 -0
- sv_cli/main.py +905 -0
- sv_cli/renderers/__init__.py +0 -0
- sv_cli/renderers/csv_renderer.py +9 -0
- sv_cli/renderers/json_renderer.py +10 -0
- sv_cli/renderers/markdown_renderer.py +9 -0
- sv_cli/renderers/table_renderer.py +9 -0
- sv_cli/renderers/text_renderer.py +9 -0
- sv_cli/resolver.py +523 -0
- sv_cli/schemas/__init__.py +0 -0
- sv_cli/schemas/api_response.py +12 -0
- sv_cli/schemas/config.py +13 -0
- sv_cli/schemas/tool_definition.py +17 -0
- sv_cli/tasks.py +168 -0
- sv_cli/utils.py +204 -0
- sv_cli-0.3.0.dist-info/METADATA +338 -0
- sv_cli-0.3.0.dist-info/RECORD +49 -0
- sv_cli-0.3.0.dist-info/WHEEL +4 -0
- sv_cli-0.3.0.dist-info/entry_points.txt +2 -0
- 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`.
|