opsai-cli 0.1.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.
opsai_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Independent OpsAI Open API client package."""
2
+
3
+ from importlib.metadata import version
4
+
5
+
6
+ def get_version() -> str:
7
+ """Read the version from the installed distribution's metadata."""
8
+ return version("opsai-cli")
opsai_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow python -m opsai_cli after installation."""
2
+
3
+ from opsai_cli.cli import main
4
+
5
+ raise SystemExit(main())
opsai_cli/business.py ADDED
@@ -0,0 +1,250 @@
1
+ """Business command registration and API v1 request admission."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from opsai_cli.client import ApiClient
11
+ from opsai_cli.contracts import (
12
+ ListingSubmit, ActivitySubmit, OptimizationSubmit, AvailableActions, ListingPrices,
13
+ )
14
+ from opsai_cli.errors import CliError
15
+ from opsai_cli.output import Output
16
+ from opsai_cli.tasks import TaskSession, task_data, outcome, validate_options
17
+
18
+
19
+ FAMILIES = {"listing": "listing", "activity": "activity", "optimization": "listing-optimization"}
20
+ MODELS = {"listing": {"submit": ListingSubmit}, "activity": {
21
+ "submit": ActivitySubmit, "actions": AvailableActions, "prices": ListingPrices,
22
+ }, "optimization": {"submit": OptimizationSubmit}}
23
+ LIMIT = 10 * 1024 * 1024
24
+
25
+
26
+ def positive(value):
27
+ try:
28
+ number = int(value)
29
+ if number <= 0:
30
+ raise ValueError
31
+ return number
32
+ except ValueError:
33
+ raise CliError("IDs and platform numbers must be positive integers.") from None
34
+
35
+
36
+ def register(groups):
37
+ for family in FAMILIES:
38
+ group = groups.add_parser(family, allow_abbrev=False)
39
+ commands = group.add_subparsers(dest="command", required=True)
40
+ stores = commands.add_parser("stores", allow_abbrev=False)
41
+ stores.add_argument("--platform", type=int)
42
+ submit = commands.add_parser("submit", allow_abbrev=False)
43
+ submit.add_argument("--file")
44
+ modes = submit.add_mutually_exclusive_group()
45
+ modes.add_argument("--wait", action="store_true")
46
+ if family != "activity":
47
+ modes.add_argument("--stream", action="store_true")
48
+ if family == "optimization":
49
+ submit.add_argument("--store-id", type=int)
50
+ submit.add_argument("--listing-sku")
51
+ submit.add_argument("--mode", choices=("update", "create"))
52
+ submit.add_argument("--request-id")
53
+ task = commands.add_parser("task", allow_abbrev=False)
54
+ task_commands = task.add_subparsers(dest="task_command", required=True)
55
+ get = task_commands.add_parser("get", allow_abbrev=False)
56
+ get.add_argument("task_id", type=int)
57
+ leaves = [stores, submit, get]
58
+ watch = task_commands.add_parser("watch", allow_abbrev=False)
59
+ watch.add_argument("task_id", type=int)
60
+ leaves.append(watch)
61
+ if family != "activity":
62
+ stop = task_commands.add_parser("stop", allow_abbrev=False)
63
+ stop.add_argument("task_id", type=int)
64
+ leaves.append(stop)
65
+ if family == "activity":
66
+ actions = commands.add_parser("actions", allow_abbrev=False)
67
+ actions.add_argument("--file")
68
+ actions.add_argument("--store-id", type=int)
69
+ actions.add_argument("--listing-sku")
70
+ actions.add_argument("--platform", type=int)
71
+ prices = commands.add_parser("prices", allow_abbrev=False)
72
+ prices.add_argument("--file", required=True)
73
+ leaves.extend([actions, prices])
74
+ for command in ("template", "validate"):
75
+ local = commands.add_parser(command, allow_abbrev=False)
76
+ local.add_argument("--kind", choices=tuple(MODELS[family]), default="submit")
77
+ if command == "template":
78
+ local.add_argument("--out", required=True)
79
+ local.add_argument("--force", action="store_true")
80
+ else:
81
+ local.add_argument("--file", required=True)
82
+ leaves.append(local)
83
+ for leaf in leaves:
84
+ # SUPPRESS preserves a global --profile passed before the group.
85
+ leaf.add_argument("--profile", default=argparse.SUPPRESS)
86
+ leaf.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
87
+ if leaf.get_default("kind") is None:
88
+ leaf.add_argument("--request-timeout", type=float, default=30)
89
+ leaf.add_argument("--wait-timeout", type=float, default=1800)
90
+ leaf.add_argument("--poll-interval", type=float, default=5)
91
+ leaf.add_argument("--max-retries", type=int, default=2)
92
+
93
+
94
+ def read_json(filename):
95
+ def reject_constant(value):
96
+ raise ValueError
97
+
98
+ def unique_pairs(pairs):
99
+ data = {}
100
+ for key, value in pairs:
101
+ if key in data:
102
+ raise ValueError
103
+ data[key] = value
104
+ return data
105
+
106
+ try:
107
+ if filename == "-":
108
+ binary = getattr(sys.stdin, "buffer", None)
109
+ content = binary.read(LIMIT + 1).decode("utf-8-sig") if binary is not None else sys.stdin.read(LIMIT + 1)
110
+ if len(content.encode("utf-8")) > LIMIT:
111
+ raise ValueError
112
+ else:
113
+ with Path(filename).open("rb") as stream:
114
+ content = stream.read(LIMIT + 1)
115
+ if len(content) > LIMIT:
116
+ raise ValueError
117
+ content = content.decode("utf-8-sig")
118
+ return json.loads(content, parse_constant=reject_constant, object_pairs_hook=unique_pairs)
119
+ except (OSError, ValueError, UnicodeError, RecursionError):
120
+ raise CliError("Cannot read request JSON: use UTF-8, unique fields, finite numbers and at most 10 MiB.") from None
121
+
122
+
123
+ def validate(family, kind, body):
124
+ try:
125
+ return MODELS[family][kind].model_validate(body).model_dump(mode="json")
126
+ except (ValidationError, ValueError, RecursionError) as exc:
127
+ if isinstance(exc, ValidationError):
128
+ issues = []
129
+ for error in exc.errors(include_input=False, include_context=False)[:8]:
130
+ loc = list(error["loc"])
131
+ if error["type"] == "extra_forbidden" and loc:
132
+ loc[-1] = "<unknown-field>"
133
+ path = ".".join(str(x) for x in loc) or "request"
134
+ issues.append(f"{path}: {error['type']}")
135
+ raise CliError("Request validation failed: " + "; ".join(issues)) from None
136
+ raise CliError("Request validation failed.") from None
137
+
138
+
139
+ def template(family, kind="submit"):
140
+ if kind == "actions":
141
+ return {"store_id": 1001, "listing_sku": "L-0001", "platform": 9}
142
+ if kind == "prices":
143
+ return {"store_id": 1001, "listing_skus": ["L-0001"], "action_id": 1977747, "platform": 9}
144
+ if family == "listing":
145
+ return {"request_id": "replace-with-stable-listing-request-id", "store_ids": [1001],
146
+ "prices": [{"store_id": 1001, "site_prices": [{"site": "RU", "price": 1999, "currency": "RUB"}]}],
147
+ "products": [{"sku": "YOUR-SKU-001", "title": "Your product title",
148
+ "images": {"main_img": "https://cdn.example.com/product.jpg"}}]}
149
+ if family == "activity":
150
+ return {"request_id": "replace-with-stable-activity-request-id", "store_id": 1001,
151
+ "action_id": 1977747, "items": [{"listing_sku": "L-0001", "action_price": 1899}]}
152
+ return {"request_id": "replace-with-stable-optimization-request-id", "store_id": 1001,
153
+ "listing_sku": "L-0001", "mode": "update"}
154
+
155
+
156
+ def request_body(args):
157
+ fields = ("store_id", "listing_sku", "mode", "request_id") if args.group == "optimization" else ("store_id", "listing_sku", "platform")
158
+ flags = {field: getattr(args, field) for field in fields if getattr(args, field, None) is not None}
159
+ filename = getattr(args, "file", None)
160
+ if filename is not None:
161
+ if flags:
162
+ raise CliError("Do not mix --file with business parameter flags.")
163
+ body = read_json(filename)
164
+ elif flags:
165
+ body = flags
166
+ else:
167
+ raise CliError("Provide --file (use - for stdin), or all required shortcut parameters.")
168
+ return validate(args.group, args.command, body)
169
+
170
+
171
+ def run(args):
172
+ family, command = args.group, args.command
173
+ output = Output(getattr(args, "output", None))
174
+ if command == "template":
175
+ # Exclusive creation protects user data unless overwrite is explicit.
176
+ content = template(family, args.kind)
177
+ validate(family, args.kind, content)
178
+ try:
179
+ with Path(args.out).open("w" if args.force else "x", encoding="utf-8") as stream:
180
+ json.dump(content, stream, ensure_ascii=False, indent=2)
181
+ stream.write("\n")
182
+ except FileExistsError:
183
+ raise CliError("Template target exists; use --force to overwrite it.") from None
184
+ except OSError:
185
+ raise CliError("Cannot write template file; check the directory and permissions.") from None
186
+ output.emit({"template_written": True, "kind": args.kind, "message": "Replace examples and use a stable request_id before submission."})
187
+ return 0
188
+ if command == "validate":
189
+ validate(family, args.kind, read_json(args.file))
190
+ output.emit({"valid": True, "message": "Server permissions and platform acceptance have not been checked."})
191
+ return 0
192
+ path = f"/{FAMILIES[family]}/v1"
193
+ method, body, params = "GET", None, None
194
+ if command == "stores":
195
+ platform = args.platform
196
+ if platform is not None:
197
+ positive(str(platform))
198
+ allowed = {9} if family == "activity" else {1, 2, 3, 9, 10, 11, 12}
199
+ if platform not in allowed:
200
+ raise CliError("Platform is not supported by this API family.")
201
+ params = {"platform": platform}
202
+ path += "/stores"
203
+ elif command == "task":
204
+ path += f"/tasks/{positive(str(args.task_id))}"
205
+ else:
206
+ body = request_body(args) # Validate before reading credentials or sending.
207
+ method = "POST"
208
+ path += {"submit": "/tasks", "actions": "/available-actions", "prices": "/listing-prices"}[command]
209
+ validate_options(args.request_timeout, args.wait_timeout, args.poll_interval, args.max_retries)
210
+ with ApiClient.from_profile(getattr(args, "profile", None)) as client:
211
+ output.secrets += (client._key,)
212
+ waiting = (getattr(args, "wait", False) or getattr(args, "stream", False)
213
+ or (command == "task" and args.task_command in {"watch", "stop"}))
214
+ session = TaskSession(client, output, family=family, prefix=f"/{FAMILIES[family]}/v1",
215
+ request_timeout=args.request_timeout, wait_timeout=args.wait_timeout,
216
+ poll_interval=args.poll_interval, max_retries=args.max_retries, waiting=waiting)
217
+ if command == "submit":
218
+ session.request_id = body["request_id"]
219
+ if command == "task":
220
+ session.task_id = args.task_id
221
+ try:
222
+ if command == "submit" and getattr(args, "stream", False):
223
+ result = session.submit_stream(body)
224
+ elif command == "task" and args.task_command == "watch":
225
+ result = session.watch()
226
+ elif command == "task" and args.task_command == "stop":
227
+ result = session.stop()
228
+ else:
229
+ result = session.request(method, path, body=body, params=params)
230
+ if command == "submit":
231
+ data = task_data(result)
232
+ session.accepted(data)
233
+ if args.wait:
234
+ result = session.watch()
235
+ if not (command == "submit" and not waiting):
236
+ output.emit(result["data"] if output.mode == "ndjson" and waiting else result)
237
+ elif output.mode != "ndjson":
238
+ output.emit(result)
239
+ # Submit-only and task get report request success, not business success.
240
+ if waiting and not (command == "task" and args.task_command == "stop"):
241
+ return outcome(family, task_data(result, session.task_id))
242
+ return 0
243
+ except CliError as exc:
244
+ session.recovery_note()
245
+ output.error(exc)
246
+ return exc.exit_code
247
+ except KeyboardInterrupt:
248
+ session.recovery_note()
249
+ output.error(CliError("Local command interrupted; no stop request was sent automatically.", 130))
250
+ return 130
opsai_cli/cli.py ADDED
@@ -0,0 +1,98 @@
1
+ """Console entry point. Business commands are added in later milestones."""
2
+
3
+ import argparse
4
+ from collections.abc import Sequence
5
+ import getpass
6
+ import sys
7
+ import warnings
8
+ import os
9
+
10
+ from opsai_cli import get_version
11
+ from opsai_cli.config import ConfigStore, select_profile
12
+ from opsai_cli.credentials import CredentialStore
13
+ from opsai_cli.errors import CliError
14
+
15
+
16
+ class SafeArgumentParser(argparse.ArgumentParser):
17
+ def error(self, message):
18
+ # argparse normally repeats invalid input, potentially including a key
19
+ # mistakenly provided as a positional argument or unknown option.
20
+ from opsai_cli.output import Output
21
+ Output().error(CliError("Invalid command arguments; run --help for usage."))
22
+ self.exit(2)
23
+
24
+
25
+ def main(argv: Sequence[str] | None = None) -> int:
26
+ parser = SafeArgumentParser(
27
+ prog="opsai",
28
+ description="OpsAI Open API command-line client.",
29
+ epilog="M4/M5 provide task waiting, bounded retries, SSE and explicit task stop.",
30
+ allow_abbrev=False,
31
+ )
32
+ parser.add_argument("--version", action="version", version=f"%(prog)s {get_version()}")
33
+ parser.add_argument("--profile", default=argparse.SUPPRESS, help="Profile name (otherwise OPSAI_PROFILE or default).")
34
+ parser.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
35
+ groups = parser.add_subparsers(dest="group")
36
+ config = groups.add_parser("config", allow_abbrev=False)
37
+ config_commands = config.add_subparsers(dest="command", required=True)
38
+ set_profile = config_commands.add_parser("set-profile", allow_abbrev=False)
39
+ set_profile.add_argument("name")
40
+ config_commands.add_parser("profiles", allow_abbrev=False)
41
+ auth = groups.add_parser("auth", allow_abbrev=False)
42
+ auth_commands = auth.add_subparsers(dest="command", required=True)
43
+ configure = auth_commands.add_parser("configure", allow_abbrev=False)
44
+ configure.add_argument("--profile", default=argparse.SUPPRESS)
45
+ for leaf in (set_profile, config_commands.choices["profiles"], configure):
46
+ leaf.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
47
+ # Load business models only when needed; help/version stay lightweight.
48
+ business_groups = {"listing", "activity", "optimization"}
49
+ supplied = list(argv) if argv is not None else sys.argv[1:]
50
+ if any(arg in business_groups for arg in supplied):
51
+ from opsai_cli.business import register
52
+ register(groups)
53
+ else:
54
+ for name in sorted(business_groups):
55
+ groups.add_parser(name, help="Use this command with --help for business operations.")
56
+ args = parser.parse_args(argv)
57
+ try:
58
+ if args.group in business_groups:
59
+ from opsai_cli.business import run
60
+ return run(args)
61
+ elif args.group == "config":
62
+ from opsai_cli.output import Output
63
+ output = Output(getattr(args, "output", None))
64
+ store = ConfigStore()
65
+ if args.command == "set-profile":
66
+ address = store.set_profile(args.name)
67
+ output.emit({"profile": args.name, "base_url": address})
68
+ else:
69
+ profiles = store.load()["profiles"]
70
+ if not profiles:
71
+ profiles = {"default": {"base_url": store.get("default")}}
72
+ output.emit([{"profile": name, "base_url": entry["base_url"]} for name, entry in sorted(profiles.items())])
73
+ elif args.group == "auth":
74
+ store = ConfigStore()
75
+ name = select_profile(getattr(args, "profile", None))
76
+ address = store.get(name)
77
+ if not sys.stdin.isatty():
78
+ raise CliError("Interactive terminal required; use OPSAI_API_KEY for non-interactive requests.")
79
+ try:
80
+ with warnings.catch_warnings():
81
+ warnings.simplefilter("error", getpass.GetPassWarning)
82
+ key = getpass.getpass(f"API Key for {name} ({address}): ")
83
+ except (getpass.GetPassWarning, EOFError):
84
+ raise CliError("Hidden input unavailable; use OPSAI_API_KEY instead.") from None
85
+ CredentialStore().save(name, address, key)
86
+ from opsai_cli.output import Output
87
+ Output(getattr(args, "output", None)).emit({"saved": True, "message": "API Key saved to system credential storage; server permissions have not been verified."})
88
+ else:
89
+ parser.print_help()
90
+ return 0
91
+ except CliError as exc:
92
+ from opsai_cli.output import Output
93
+ Output(getattr(args, "output", None), secrets=(os.environ.get("OPSAI_API_KEY", ""),)).error(exc)
94
+ return exc.exit_code
95
+ except KeyboardInterrupt:
96
+ from opsai_cli.output import Output
97
+ Output(getattr(args, "output", None)).error(CliError("Local command interrupted.", 130))
98
+ return 130
opsai_cli/client.py ADDED
@@ -0,0 +1,120 @@
1
+ """Synchronous Open API transport. Retry/task orchestration belongs to M4."""
2
+
3
+ import math
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from email.utils import parsedate_to_datetime
7
+
8
+ import httpx
9
+
10
+ from opsai_cli.config import normalize_base_url, select_profile, ConfigStore
11
+ from opsai_cli.credentials import CredentialStore, validate_key
12
+ from opsai_cli.errors import CliError
13
+
14
+
15
+ class ApiClient:
16
+ def __init__(self, base_url: str, api_key: str, *, request_timeout: float = 30,
17
+ transport: httpx.BaseTransport | None = None):
18
+ self.base_url = normalize_base_url(base_url)
19
+ self._key = validate_key(api_key)
20
+ if (isinstance(request_timeout, bool) or not isinstance(request_timeout, (int, float))
21
+ or not math.isfinite(request_timeout) or request_timeout <= 0):
22
+ raise CliError("Request timeout must be a finite positive number of seconds.")
23
+ self._client = httpx.Client(
24
+ headers={"Authorization": f"Bearer {self._key}", "Accept": "application/json"},
25
+ timeout=httpx.Timeout(request_timeout, connect=min(10, request_timeout)),
26
+ verify=True, follow_redirects=False, trust_env=False, transport=transport,
27
+ )
28
+
29
+ @classmethod
30
+ def from_profile(cls, profile=None, *, config=None, credentials=None, **kwargs):
31
+ name = select_profile(profile)
32
+ base_url = (config or ConfigStore()).get(name)
33
+ key = (credentials or CredentialStore()).resolve(name, base_url)
34
+ return cls(base_url, key, **kwargs)
35
+
36
+ def close(self):
37
+ self._client.close()
38
+
39
+ def __enter__(self):
40
+ return self
41
+
42
+ def __exit__(self, *args):
43
+ self.close()
44
+
45
+ def _url(self, path):
46
+ if (not isinstance(path, str) or not re.fullmatch(r"/[A-Za-z0-9_/-]+", path)
47
+ or "//" in path):
48
+ raise CliError("Use a relative API path without a host, query or traversal.")
49
+ return self.base_url + "/api/open" + path
50
+
51
+ @staticmethod
52
+ def _status(response):
53
+ status = response.status_code
54
+ if 200 <= status < 300:
55
+ return
56
+ retry_after = None
57
+ header = response.headers.get("Retry-After", "")
58
+ try:
59
+ retry_after = float(header)
60
+ if not math.isfinite(retry_after) or retry_after < 0:
61
+ retry_after = None
62
+ except ValueError:
63
+ try:
64
+ date = parsedate_to_datetime(header)
65
+ if date.tzinfo is not None:
66
+ retry_after = max(0, (date - datetime.now(timezone.utc)).total_seconds())
67
+ except (ValueError, TypeError, OverflowError):
68
+ pass
69
+ code = 3 if status in (401, 403) else 5 if status >= 500 or 300 <= status < 400 else 4
70
+ message = {
71
+ 401: "API authentication failed; check the Key and account status.",
72
+ 403: "API permission denied; check scopes, business permissions and resource ownership.",
73
+ 429: "API rate limit exceeded; retry later.",
74
+ }.get(status, "API request rejected; check service configuration and request parameters.")
75
+ if 300 <= status < 400:
76
+ message = "API redirect refused; the CLI uses the official service root."
77
+ raise CliError(f"HTTP {status}: {message}", code, status=status,
78
+ retryable=status in {408, 425, 429, 500, 502, 503, 504}, retry_after=retry_after)
79
+
80
+ @staticmethod
81
+ def _payload(response):
82
+ try:
83
+ payload = response.json()
84
+ except ValueError:
85
+ raise CliError("API returned an invalid JSON response.", 5) from None
86
+ if (not isinstance(payload, dict) or type(payload.get("code")) is not int
87
+ or "data" not in payload or not isinstance(payload.get("message"), str)):
88
+ raise CliError("API returned an unsupported response format.", 5)
89
+ if payload["code"] != 0:
90
+ code = 3 if payload["code"] in (401, 403) else 4
91
+ raise CliError("API reported a business error; check request parameters and permissions.", code)
92
+ return payload
93
+
94
+ def request_json(self, method, path, *, params=None, json=None, timeout=None):
95
+ url = self._url(path)
96
+ options = {} if timeout is None else {"timeout": httpx.Timeout(timeout, connect=min(10, timeout))}
97
+ try:
98
+ response = self._client.request(method, url, params=params, json=json, **options)
99
+ except httpx.TimeoutException:
100
+ raise CliError("API request timed out; a submitted operation may still have been accepted.", 5, retryable=True) from None
101
+ except httpx.RequestError:
102
+ raise CliError("Cannot reach the API service; check connectivity and TLS configuration.", 5, retryable=True) from None
103
+ except (ValueError, TypeError):
104
+ raise CliError("Cannot encode API request parameters.") from None
105
+ self._status(response)
106
+ return self._payload(response)
107
+
108
+ def stream_events(self, path, body, *, timeout=30, check=lambda: None):
109
+ from opsai_cli.sse import parse_events, utf8_lines
110
+ url = self._url(path)
111
+ try:
112
+ with self._client.stream("POST", url, json=body,
113
+ headers={"Accept": "text/event-stream"},
114
+ timeout=httpx.Timeout(timeout, connect=min(10, timeout))) as response:
115
+ self._status(response)
116
+ if response.headers.get("content-type", "").split(";", 1)[0].strip() != "text/event-stream":
117
+ raise CliError("API did not return an SSE stream; submission outcome may be unknown.", 5)
118
+ yield from parse_events(utf8_lines(response.iter_bytes(), check), check)
119
+ except httpx.RequestError:
120
+ raise CliError("SSE connection failed; submission outcome may be unknown.", 5, retryable=True) from None
opsai_cli/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """Non-secret, versioned profile configuration."""
2
+
3
+ import ipaddress
4
+ from contextlib import suppress
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import tempfile
10
+ from urllib.parse import urlsplit
11
+
12
+ from opsai_cli.errors import CliError
13
+
14
+ DEFAULT_BASE_URL = "https://www.krossray.com"
15
+
16
+
17
+ def profile_name(value: str) -> str:
18
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", value):
19
+ raise CliError("Profile names must contain 1-64 letters, digits, underscores or hyphens.")
20
+ return value
21
+
22
+
23
+ def select_profile(explicit: str | None = None) -> str:
24
+ return profile_name(explicit if explicit is not None else os.environ.get("OPSAI_PROFILE", "default"))
25
+
26
+
27
+ def normalize_base_url(value: str) -> str:
28
+ try:
29
+ if not isinstance(value, str) or not value or any(c.isspace() or ord(c) < 32 for c in value):
30
+ raise ValueError
31
+ if any(c in value for c in ("\\", "%", "?", "#")):
32
+ raise ValueError
33
+ parts = urlsplit(value)
34
+ if (parts.scheme not in {"https", "http"} or not parts.hostname
35
+ or parts.username is not None or parts.password is not None
36
+ or parts.path not in {"", "/"}):
37
+ raise ValueError
38
+ host = parts.hostname.lower()
39
+ if host != "localhost":
40
+ try:
41
+ address = ipaddress.ip_address(host)
42
+ local = address.is_loopback
43
+ host = address.compressed
44
+ except ValueError:
45
+ local = False
46
+ host = host.encode("idna").decode("ascii")
47
+ if not re.fullmatch(r"[a-z0-9.-]+", host):
48
+ raise ValueError
49
+ else:
50
+ local = True
51
+ if parts.scheme == "http" and not local:
52
+ raise ValueError
53
+ port = parts.port
54
+ if port is not None and port < 1:
55
+ raise ValueError
56
+ authority = f"[{host}]" if ":" in host else host
57
+ if port is not None and port != (443 if parts.scheme == "https" else 80):
58
+ authority += f":{port}"
59
+ return f"{parts.scheme}://{authority}"
60
+ except (ValueError, UnicodeError):
61
+ raise CliError("Use an HTTPS service root without credentials, path, query or fragment; HTTP is allowed only for localhost/loopback.") from None
62
+
63
+
64
+ def default_config_dir() -> Path:
65
+ override = os.environ.get("OPSAI_CONFIG_DIR")
66
+ if override:
67
+ return Path(override).expanduser()
68
+ if os.name == "nt":
69
+ root = os.environ.get("APPDATA")
70
+ return (Path(root) if root else Path.home() / "AppData/Roaming") / "opsai"
71
+ root = os.environ.get("XDG_CONFIG_HOME")
72
+ return (Path(root) if root else Path.home() / ".config") / "opsai"
73
+
74
+
75
+ class ConfigStore:
76
+ def __init__(self, directory: Path | None = None):
77
+ self.path = (directory if directory is not None else default_config_dir()) / "config.json"
78
+
79
+ def load(self) -> dict:
80
+ try:
81
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
82
+ except FileNotFoundError:
83
+ return {"version": 1, "profiles": {}}
84
+ except (OSError, ValueError, UnicodeError):
85
+ raise CliError("Cannot read profile configuration; check config.json permissions and JSON format.") from None
86
+ if (not isinstance(raw, dict) or set(raw) != {"version", "profiles"}
87
+ or type(raw["version"]) is not int or raw["version"] != 1
88
+ or not isinstance(raw["profiles"], dict)):
89
+ raise CliError("Unsupported profile configuration format.")
90
+ for name, entry in raw["profiles"].items():
91
+ profile_name(name)
92
+ if not isinstance(entry, dict) or set(entry) != {"base_url"}:
93
+ raise CliError("Profile configuration may contain only base_url; keep keys in the system credential store or OPSAI_API_KEY.")
94
+ # Legacy profiles may contain custom origins. Never use them for
95
+ # requests: the public CLI always connects to the official site.
96
+ entry["base_url"] = DEFAULT_BASE_URL
97
+ return raw
98
+
99
+ def get(self, name: str) -> str:
100
+ name = profile_name(name)
101
+ return DEFAULT_BASE_URL
102
+
103
+ def set_profile(self, name: str) -> str:
104
+ name, base_url = profile_name(name), DEFAULT_BASE_URL
105
+ data = self.load()
106
+ data["profiles"][name] = {"base_url": base_url}
107
+ temporary = None
108
+ try:
109
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
110
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent,
111
+ prefix=".config-", suffix=".tmp", delete=False) as stream:
112
+ temporary = Path(stream.name)
113
+ json.dump(data, stream, ensure_ascii=False, indent=2)
114
+ stream.write("\n")
115
+ stream.flush()
116
+ os.fsync(stream.fileno())
117
+ temporary.replace(self.path)
118
+ except OSError:
119
+ raise CliError("Cannot save profile configuration; check directory permissions.") from None
120
+ finally:
121
+ if temporary is not None:
122
+ with suppress(OSError):
123
+ temporary.unlink(missing_ok=True)
124
+ return base_url