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/config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Configuration, profiles, and API-key resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import AuthError, ConfigError
|
|
11
|
+
from .utils import app_dir, legacy_app_dir, mask_mapping, read_json_file, write_json_file
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://ai.seovendor.co/api/"
|
|
14
|
+
DEFAULT_CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_path() -> Path:
|
|
18
|
+
return app_dir() / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def legacy_config_path() -> Path:
|
|
22
|
+
return legacy_app_dir() / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_config() -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"default_profile": "default",
|
|
28
|
+
"profiles": {
|
|
29
|
+
"default": {
|
|
30
|
+
"api_key": None,
|
|
31
|
+
"base_url": DEFAULT_BASE_URL,
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"defaults": {
|
|
35
|
+
"format": "pretty",
|
|
36
|
+
"cache_ttl_seconds": DEFAULT_CACHE_TTL_SECONDS,
|
|
37
|
+
"strict": False,
|
|
38
|
+
"fuzzy": True,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config() -> dict[str, Any]:
|
|
44
|
+
path = config_path()
|
|
45
|
+
if not path.exists():
|
|
46
|
+
# When a caller explicitly sets SV_HOME or SEOVENDOR_HOME, keep all
|
|
47
|
+
# reads isolated to that directory. Otherwise, read the legacy default
|
|
48
|
+
# path once as a migration aid for pre-rename installs.
|
|
49
|
+
if os.environ.get("SV_HOME") or os.environ.get("SEOVENDOR_HOME"):
|
|
50
|
+
return default_config()
|
|
51
|
+
legacy_path = legacy_config_path()
|
|
52
|
+
if legacy_path.exists():
|
|
53
|
+
path = legacy_path
|
|
54
|
+
else:
|
|
55
|
+
return default_config()
|
|
56
|
+
try:
|
|
57
|
+
loaded = read_json_file(path, default_config())
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise ConfigError(str(exc)) from exc
|
|
60
|
+
cfg = default_config()
|
|
61
|
+
cfg.update({k: v for k, v in loaded.items() if k != "profiles"})
|
|
62
|
+
cfg["profiles"].update(loaded.get("profiles", {}))
|
|
63
|
+
cfg.setdefault("defaults", {}).update(loaded.get("defaults", {}))
|
|
64
|
+
if cfg.get("default_profile") not in cfg.get("profiles", {}):
|
|
65
|
+
cfg["default_profile"] = "default"
|
|
66
|
+
cfg["profiles"].setdefault("default", {"api_key": None, "base_url": DEFAULT_BASE_URL})
|
|
67
|
+
return cfg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_config(cfg: dict[str, Any]) -> None:
|
|
71
|
+
write_json_file(config_path(), cfg)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def masked_config() -> dict[str, Any]:
|
|
75
|
+
return mask_mapping(load_config())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_profile_name(cfg: dict[str, Any], profile: str | None = None) -> str:
|
|
79
|
+
return profile or cfg.get("default_profile") or "default"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_profile(cfg: dict[str, Any], profile: str | None = None, create: bool = False) -> dict[str, Any]:
|
|
83
|
+
name = get_profile_name(cfg, profile)
|
|
84
|
+
profiles = cfg.setdefault("profiles", {})
|
|
85
|
+
if name not in profiles:
|
|
86
|
+
if not create:
|
|
87
|
+
raise ConfigError(f'Profile "{name}" does not exist. Create it with: sv profile create {name}')
|
|
88
|
+
profiles[name] = {"api_key": None, "base_url": DEFAULT_BASE_URL}
|
|
89
|
+
profiles[name].setdefault("base_url", DEFAULT_BASE_URL)
|
|
90
|
+
profiles[name].setdefault("api_key", None)
|
|
91
|
+
return profiles[name]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_base_url(cli_base_url: str | None = None, profile: str | None = None) -> str:
|
|
95
|
+
if cli_base_url:
|
|
96
|
+
return normalize_base_url(cli_base_url)
|
|
97
|
+
cfg = load_config()
|
|
98
|
+
value = get_profile(cfg, profile).get("base_url") or DEFAULT_BASE_URL
|
|
99
|
+
return normalize_base_url(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalize_base_url(base_url: str) -> str:
|
|
103
|
+
return base_url.rstrip("/") + "/"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_api_key(
|
|
107
|
+
*,
|
|
108
|
+
cli_api_key: str | None = None,
|
|
109
|
+
profile: str | None = None,
|
|
110
|
+
allow_prompt: bool = False,
|
|
111
|
+
non_interactive: bool = False,
|
|
112
|
+
) -> str:
|
|
113
|
+
if cli_api_key:
|
|
114
|
+
return cli_api_key
|
|
115
|
+
env_key = os.environ.get("SV_API_KEY") or os.environ.get("SEOVENDOR_API_KEY")
|
|
116
|
+
if env_key:
|
|
117
|
+
return env_key
|
|
118
|
+
cfg = load_config()
|
|
119
|
+
stored = get_profile(cfg, profile).get("api_key")
|
|
120
|
+
if stored:
|
|
121
|
+
return stored
|
|
122
|
+
if allow_prompt and not non_interactive:
|
|
123
|
+
entered = getpass.getpass("SV API key: ").strip()
|
|
124
|
+
if entered:
|
|
125
|
+
return entered
|
|
126
|
+
raise AuthError(
|
|
127
|
+
"Missing SV API key. Set SV_API_KEY, run `sv auth set`, "
|
|
128
|
+
"or pass --api-key. SEOVENDOR_API_KEY is also accepted as a legacy fallback. "
|
|
129
|
+
"Avoid --api-key in shared shells because it may be saved in history."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def set_api_key(api_key: str, profile: str | None = None) -> None:
|
|
134
|
+
cfg = load_config()
|
|
135
|
+
prof = get_profile(cfg, profile, create=True)
|
|
136
|
+
prof["api_key"] = api_key
|
|
137
|
+
save_config(cfg)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def clear_api_key(profile: str | None = None) -> None:
|
|
141
|
+
cfg = load_config()
|
|
142
|
+
prof = get_profile(cfg, profile, create=True)
|
|
143
|
+
prof["api_key"] = None
|
|
144
|
+
save_config(cfg)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def set_profile_value(profile: str, key: str, value: Any) -> None:
|
|
148
|
+
cfg = load_config()
|
|
149
|
+
prof = get_profile(cfg, profile, create=True)
|
|
150
|
+
prof[key] = value
|
|
151
|
+
save_config(cfg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def create_profile(name: str, *, base_url: str | None = None, api_key: str | None = None) -> None:
|
|
155
|
+
cfg = load_config()
|
|
156
|
+
if name in cfg.setdefault("profiles", {}):
|
|
157
|
+
raise ConfigError(f'Profile "{name}" already exists.')
|
|
158
|
+
cfg["profiles"][name] = {
|
|
159
|
+
"api_key": api_key,
|
|
160
|
+
"base_url": normalize_base_url(base_url or DEFAULT_BASE_URL),
|
|
161
|
+
}
|
|
162
|
+
save_config(cfg)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def delete_profile(name: str) -> None:
|
|
166
|
+
cfg = load_config()
|
|
167
|
+
if name == "default":
|
|
168
|
+
raise ConfigError('The "default" profile cannot be deleted.')
|
|
169
|
+
if name not in cfg.setdefault("profiles", {}):
|
|
170
|
+
raise ConfigError(f'Profile "{name}" does not exist.')
|
|
171
|
+
del cfg["profiles"][name]
|
|
172
|
+
if cfg.get("default_profile") == name:
|
|
173
|
+
cfg["default_profile"] = "default"
|
|
174
|
+
save_config(cfg)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def use_profile(name: str) -> None:
|
|
178
|
+
cfg = load_config()
|
|
179
|
+
if name not in cfg.setdefault("profiles", {}):
|
|
180
|
+
raise ConfigError(f'Profile "{name}" does not exist. Create it with: sv profile create {name}')
|
|
181
|
+
cfg["default_profile"] = name
|
|
182
|
+
save_config(cfg)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def set_config_value(key: str, value: Any, profile: str | None = None) -> None:
|
|
186
|
+
cfg = load_config()
|
|
187
|
+
if key == "base_url":
|
|
188
|
+
get_profile(cfg, profile, create=True)[key] = normalize_base_url(str(value))
|
|
189
|
+
elif key == "api_key":
|
|
190
|
+
get_profile(cfg, profile, create=True)[key] = str(value)
|
|
191
|
+
elif key == "default_profile":
|
|
192
|
+
if value not in cfg.get("profiles", {}):
|
|
193
|
+
raise ConfigError(f'Profile "{value}" does not exist.')
|
|
194
|
+
cfg["default_profile"] = str(value)
|
|
195
|
+
else:
|
|
196
|
+
cfg.setdefault("defaults", {})[key] = value
|
|
197
|
+
save_config(cfg)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_config_value(key: str, profile: str | None = None) -> Any:
|
|
201
|
+
cfg = load_config()
|
|
202
|
+
if key in {"base_url", "api_key"}:
|
|
203
|
+
value = get_profile(cfg, profile).get(key)
|
|
204
|
+
elif key == "default_profile":
|
|
205
|
+
value = cfg.get("default_profile")
|
|
206
|
+
else:
|
|
207
|
+
value = cfg.setdefault("defaults", {}).get(key)
|
|
208
|
+
if key == "api_key" and value:
|
|
209
|
+
return "***masked***" + str(value)[-4:]
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def reset_config() -> None:
|
|
214
|
+
path = config_path()
|
|
215
|
+
if path.exists():
|
|
216
|
+
path.unlink()
|
sv_cli/definitions.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Dynamic SV API discovery and local definitions cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urljoin
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .adapters import TOOL_ADAPTERS, all_command_names, tool_display_name
|
|
15
|
+
from .config import DEFAULT_BASE_URL, load_config, normalize_base_url
|
|
16
|
+
from .errors import ConfigError, NetworkError
|
|
17
|
+
from .utils import app_dir, normalize_text, read_json_file, write_json_file
|
|
18
|
+
|
|
19
|
+
CACHE_VERSION = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cache_path() -> Path:
|
|
23
|
+
return app_dir() / "cache" / "definitions.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utc_now() -> str:
|
|
27
|
+
return dt.datetime.now(dt.timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_timestamp(value: str | None) -> float:
|
|
31
|
+
if not value:
|
|
32
|
+
return 0.0
|
|
33
|
+
try:
|
|
34
|
+
return dt.datetime.fromisoformat(value).timestamp()
|
|
35
|
+
except ValueError:
|
|
36
|
+
return 0.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_cache(base_url: str = DEFAULT_BASE_URL) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"version": CACHE_VERSION,
|
|
42
|
+
"base_url": normalize_base_url(base_url),
|
|
43
|
+
"fetched_at": None,
|
|
44
|
+
"root": {},
|
|
45
|
+
"tools": {},
|
|
46
|
+
"warnings": [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DefinitionsManager:
|
|
51
|
+
"""Fetch, cache, and resolve API root/tool definitions."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, base_url: str | None = None, *, ttl_seconds: int | None = None) -> None:
|
|
54
|
+
cfg = load_config()
|
|
55
|
+
profile_name = cfg.get("default_profile") or "default"
|
|
56
|
+
profile = cfg.get("profiles", {}).get(profile_name, {})
|
|
57
|
+
self.base_url = normalize_base_url(base_url or profile.get("base_url") or DEFAULT_BASE_URL)
|
|
58
|
+
self.ttl_seconds = int(
|
|
59
|
+
ttl_seconds
|
|
60
|
+
if ttl_seconds is not None
|
|
61
|
+
else cfg.get("defaults", {}).get("cache_ttl_seconds", 24 * 60 * 60)
|
|
62
|
+
)
|
|
63
|
+
self.path = cache_path()
|
|
64
|
+
|
|
65
|
+
def load_cache(self) -> dict[str, Any]:
|
|
66
|
+
try:
|
|
67
|
+
cached = read_json_file(self.path, default_cache(self.base_url))
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise ConfigError(str(exc)) from exc
|
|
70
|
+
if not cached:
|
|
71
|
+
return default_cache(self.base_url)
|
|
72
|
+
cached.setdefault("version", CACHE_VERSION)
|
|
73
|
+
cached.setdefault("base_url", self.base_url)
|
|
74
|
+
cached.setdefault("root", {})
|
|
75
|
+
cached.setdefault("tools", {})
|
|
76
|
+
cached.setdefault("warnings", [])
|
|
77
|
+
self.ensure_supplemental_tools(cached)
|
|
78
|
+
return cached
|
|
79
|
+
|
|
80
|
+
def save_cache(self, cache: dict[str, Any]) -> None:
|
|
81
|
+
cache["version"] = CACHE_VERSION
|
|
82
|
+
cache["base_url"] = self.base_url
|
|
83
|
+
write_json_file(self.path, cache)
|
|
84
|
+
|
|
85
|
+
def clear_cache(self) -> bool:
|
|
86
|
+
if self.path.exists():
|
|
87
|
+
self.path.unlink()
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def is_stale(self, cache: dict[str, Any]) -> bool:
|
|
92
|
+
if not cache.get("root"):
|
|
93
|
+
return True
|
|
94
|
+
fetched = parse_timestamp(cache.get("fetched_at"))
|
|
95
|
+
return (time.time() - fetched) > self.ttl_seconds
|
|
96
|
+
|
|
97
|
+
def get_cache(self, *, refresh: bool = False, allow_stale: bool = True) -> dict[str, Any]:
|
|
98
|
+
cache = self.load_cache()
|
|
99
|
+
if refresh or self.is_stale(cache):
|
|
100
|
+
try:
|
|
101
|
+
return self.refresh_all()
|
|
102
|
+
except Exception as exc: # noqa: BLE001 - cache fallback should be broad
|
|
103
|
+
if allow_stale and cache.get("root"):
|
|
104
|
+
cache.setdefault("warnings", []).append(
|
|
105
|
+
f"Definitions refresh failed; using stale cache: {exc}"
|
|
106
|
+
)
|
|
107
|
+
return cache
|
|
108
|
+
if isinstance(exc, NetworkError):
|
|
109
|
+
raise
|
|
110
|
+
raise NetworkError(f"Could not fetch API definitions and no usable cache exists: {exc}") from exc
|
|
111
|
+
return cache
|
|
112
|
+
|
|
113
|
+
def fetch_json(self, url: str) -> Any:
|
|
114
|
+
try:
|
|
115
|
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
|
116
|
+
response = client.get(url)
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
return response.json()
|
|
119
|
+
except httpx.TimeoutException as exc:
|
|
120
|
+
raise NetworkError(f"Timed out while fetching {url}") from exc
|
|
121
|
+
except httpx.HTTPStatusError as exc:
|
|
122
|
+
raise NetworkError(
|
|
123
|
+
f"Definitions request failed for {url}: HTTP {exc.response.status_code}"
|
|
124
|
+
) from exc
|
|
125
|
+
except httpx.RequestError as exc:
|
|
126
|
+
raise NetworkError(f"Network error while fetching {url}: {exc}") from exc
|
|
127
|
+
except json.JSONDecodeError as exc:
|
|
128
|
+
raise NetworkError(f"Definitions endpoint did not return valid JSON: {url}") from exc
|
|
129
|
+
|
|
130
|
+
def supplemental_root_entries(self) -> dict[str, dict[str, str]]:
|
|
131
|
+
"""Return adapter-provided endpoint hints for tools missing from the API root.
|
|
132
|
+
|
|
133
|
+
These entries are a compatibility bridge for newly released tools whose
|
|
134
|
+
definitions endpoints are live before the API root advertises them.
|
|
135
|
+
They are deliberately derived from the active base URL, so profiles and
|
|
136
|
+
staging URLs still work.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
entries: dict[str, dict[str, str]] = {}
|
|
140
|
+
for canonical, adapter in TOOL_ADAPTERS.items():
|
|
141
|
+
if not adapter.endpoint_path:
|
|
142
|
+
continue
|
|
143
|
+
definitions_path = adapter.definitions_path or f"{adapter.endpoint_path.rstrip('/')}/definitions"
|
|
144
|
+
entries[canonical] = {
|
|
145
|
+
"Endpoint": join_base_url(self.base_url, adapter.endpoint_path),
|
|
146
|
+
"Definitions": join_base_url(self.base_url, definitions_path),
|
|
147
|
+
"Source": "local-adapter-fallback",
|
|
148
|
+
}
|
|
149
|
+
return entries
|
|
150
|
+
|
|
151
|
+
def ensure_supplemental_tools(self, cache: dict[str, Any]) -> None:
|
|
152
|
+
"""Merge local adapter hints into a cache without overwriting live root data."""
|
|
153
|
+
|
|
154
|
+
root = cache.setdefault("root", {})
|
|
155
|
+
tools = cache.setdefault("tools", {})
|
|
156
|
+
for tool_name, meta in self.supplemental_root_entries().items():
|
|
157
|
+
if tool_name in root:
|
|
158
|
+
continue
|
|
159
|
+
root[tool_name] = meta
|
|
160
|
+
tools.setdefault(
|
|
161
|
+
tool_name,
|
|
162
|
+
{
|
|
163
|
+
"tool": tool_name,
|
|
164
|
+
"display_name": tool_display_name(tool_name),
|
|
165
|
+
"endpoint": meta.get("Endpoint"),
|
|
166
|
+
"definitions_url": meta.get("Definitions"),
|
|
167
|
+
"definition": None,
|
|
168
|
+
"last_fetched": None,
|
|
169
|
+
"source": meta.get("Source"),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def refresh_all(self) -> dict[str, Any]:
|
|
174
|
+
root = self.fetch_json(self.base_url)
|
|
175
|
+
if not isinstance(root, dict):
|
|
176
|
+
raise NetworkError(f"API root {self.base_url} did not return a JSON object")
|
|
177
|
+
cache = default_cache(self.base_url)
|
|
178
|
+
cache["root"] = dict(root)
|
|
179
|
+
self.ensure_supplemental_tools(cache)
|
|
180
|
+
cache["fetched_at"] = utc_now()
|
|
181
|
+
warnings: list[str] = []
|
|
182
|
+
for tool_name, meta in cache["root"].items():
|
|
183
|
+
endpoint = _pick_value(meta, "Endpoint", "endpoint", "url")
|
|
184
|
+
definitions_url = _pick_value(meta, "Definitions", "definitions", "definition", "schema")
|
|
185
|
+
existing_entry = cache.get("tools", {}).get(tool_name, {})
|
|
186
|
+
tool_entry = {
|
|
187
|
+
"tool": tool_name,
|
|
188
|
+
"display_name": tool_display_name(tool_name),
|
|
189
|
+
"endpoint": endpoint or existing_entry.get("endpoint"),
|
|
190
|
+
"definitions_url": definitions_url or existing_entry.get("definitions_url"),
|
|
191
|
+
"definition": existing_entry.get("definition"),
|
|
192
|
+
"last_fetched": existing_entry.get("last_fetched"),
|
|
193
|
+
"source": existing_entry.get("source", "api-root"),
|
|
194
|
+
}
|
|
195
|
+
if tool_entry.get("definitions_url"):
|
|
196
|
+
try:
|
|
197
|
+
tool_entry["definition"] = self.fetch_json(str(tool_entry["definitions_url"]))
|
|
198
|
+
tool_entry["last_fetched"] = utc_now()
|
|
199
|
+
except Exception as exc: # noqa: BLE001 - partial cache is still useful
|
|
200
|
+
warnings.append(f"Could not fetch definitions for {tool_name}: {exc}")
|
|
201
|
+
cache["tools"][tool_name] = tool_entry
|
|
202
|
+
cache["warnings"] = warnings
|
|
203
|
+
self.save_cache(cache)
|
|
204
|
+
return cache
|
|
205
|
+
|
|
206
|
+
def refresh_tool(self, tool_name: str) -> dict[str, Any]:
|
|
207
|
+
cache = self.get_cache(allow_stale=True)
|
|
208
|
+
canonical = self.resolve_tool(tool_name, cache)
|
|
209
|
+
entry = cache.get("tools", {}).get(canonical)
|
|
210
|
+
if not entry:
|
|
211
|
+
raise ConfigError(f'Tool "{tool_name}" is not available in the API root.')
|
|
212
|
+
definitions_url = entry.get("definitions_url")
|
|
213
|
+
if not definitions_url:
|
|
214
|
+
raise NetworkError(f'Tool "{canonical}" does not expose a definitions URL in the root.')
|
|
215
|
+
entry["definition"] = self.fetch_json(str(definitions_url))
|
|
216
|
+
entry["last_fetched"] = utc_now()
|
|
217
|
+
cache["tools"][canonical] = entry
|
|
218
|
+
self.save_cache(cache)
|
|
219
|
+
return entry
|
|
220
|
+
|
|
221
|
+
def resolve_tool(self, user_value: str, cache: dict[str, Any] | None = None) -> str:
|
|
222
|
+
cache = cache or self.get_cache(allow_stale=True)
|
|
223
|
+
root_tools = set(cache.get("root", {}).keys()) | set(cache.get("tools", {}).keys())
|
|
224
|
+
if user_value in root_tools:
|
|
225
|
+
return user_value
|
|
226
|
+
alias_map = all_command_names()
|
|
227
|
+
if user_value in alias_map:
|
|
228
|
+
preferred = alias_map[user_value]
|
|
229
|
+
if preferred in root_tools:
|
|
230
|
+
return preferred
|
|
231
|
+
norm_user = normalize_text(user_value).replace(" ", "")
|
|
232
|
+
for name in root_tools:
|
|
233
|
+
if normalize_text(name).replace(" ", "") == norm_user:
|
|
234
|
+
return name
|
|
235
|
+
for alias, canonical in alias_map.items():
|
|
236
|
+
if normalize_text(alias).replace(" ", "") == norm_user and canonical in root_tools:
|
|
237
|
+
return canonical
|
|
238
|
+
known = ", ".join(sorted(root_tools)) or "none"
|
|
239
|
+
raise ConfigError(f'Unknown tool "{user_value}". Available tools: {known}')
|
|
240
|
+
|
|
241
|
+
def get_tool(self, user_value: str, *, refresh: bool = False) -> dict[str, Any]:
|
|
242
|
+
cache = self.get_cache(refresh=refresh, allow_stale=True)
|
|
243
|
+
canonical = self.resolve_tool(user_value, cache)
|
|
244
|
+
entry = cache.get("tools", {}).get(canonical)
|
|
245
|
+
if entry:
|
|
246
|
+
if entry.get("definition") is None and entry.get("definitions_url"):
|
|
247
|
+
try:
|
|
248
|
+
entry["definition"] = self.fetch_json(str(entry["definitions_url"]))
|
|
249
|
+
entry["last_fetched"] = utc_now()
|
|
250
|
+
cache.setdefault("tools", {})[canonical] = entry
|
|
251
|
+
self.save_cache(cache)
|
|
252
|
+
except Exception as exc: # noqa: BLE001 - callers can still use raw or schema-light tools
|
|
253
|
+
entry["definition_warning"] = f"Could not fetch definitions: {exc}"
|
|
254
|
+
return entry
|
|
255
|
+
meta = cache.get("root", {}).get(canonical, {})
|
|
256
|
+
entry = {
|
|
257
|
+
"tool": canonical,
|
|
258
|
+
"display_name": tool_display_name(canonical),
|
|
259
|
+
"endpoint": _pick_value(meta, "Endpoint", "endpoint", "url"),
|
|
260
|
+
"definitions_url": _pick_value(meta, "Definitions", "definitions", "definition", "schema"),
|
|
261
|
+
"definition": None,
|
|
262
|
+
"last_fetched": None,
|
|
263
|
+
}
|
|
264
|
+
cache.setdefault("tools", {})[canonical] = entry
|
|
265
|
+
self.save_cache(cache)
|
|
266
|
+
return entry
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def join_base_url(base_url: str, path: str) -> str:
|
|
270
|
+
return urljoin(normalize_base_url(base_url), path.lstrip("/"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _pick_value(data: Any, *keys: str) -> Any:
|
|
274
|
+
if not isinstance(data, dict):
|
|
275
|
+
return None
|
|
276
|
+
for key in keys:
|
|
277
|
+
if key in data:
|
|
278
|
+
return data[key]
|
|
279
|
+
lowered = {str(k).lower(): v for k, v in data.items()}
|
|
280
|
+
for key in keys:
|
|
281
|
+
if key.lower() in lowered:
|
|
282
|
+
return lowered[key.lower()]
|
|
283
|
+
return None
|
sv_cli/errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Typed exceptions and exit-code helpers for SV CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CLIError(Exception):
|
|
10
|
+
"""Base error carrying a process exit code."""
|
|
11
|
+
|
|
12
|
+
message: str
|
|
13
|
+
exit_code: int = 1
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
16
|
+
return self.message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidInputError(CLIError):
|
|
20
|
+
def __init__(self, message: str) -> None:
|
|
21
|
+
super().__init__(message, 2)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbiguousMatchError(InvalidInputError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthError(CLIError):
|
|
29
|
+
def __init__(self, message: str) -> None:
|
|
30
|
+
super().__init__(message, 3)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIError(CLIError):
|
|
34
|
+
def __init__(self, message: str) -> None:
|
|
35
|
+
super().__init__(message, 4)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NetworkError(CLIError):
|
|
39
|
+
def __init__(self, message: str) -> None:
|
|
40
|
+
super().__init__(message, 5)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TimeoutError(CLIError):
|
|
44
|
+
def __init__(self, message: str) -> None:
|
|
45
|
+
super().__init__(message, 6)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(CLIError):
|
|
49
|
+
def __init__(self, message: str) -> None:
|
|
50
|
+
super().__init__(message, 7)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnsupportedFeatureError(CLIError):
|
|
54
|
+
def __init__(self, message: str) -> None:
|
|
55
|
+
super().__init__(message, 8)
|