adaptorch-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.
- adaptorch_cli/__init__.py +1 -0
- adaptorch_cli/__main__.py +3 -0
- adaptorch_cli/cli.py +351 -0
- adaptorch_cli/config_store.py +215 -0
- adaptorch_cli/parser.py +115 -0
- adaptorch_cli/py.typed +0 -0
- adaptorch_cli-0.1.0.dist-info/METADATA +46 -0
- adaptorch_cli-0.1.0.dist-info/RECORD +12 -0
- adaptorch_cli-0.1.0.dist-info/WHEEL +5 -0
- adaptorch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- adaptorch_cli-0.1.0.dist-info/licenses/LICENSE +14 -0
- adaptorch_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AdaptOrch command-line client."""
|
adaptorch_cli/cli.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Never, Protocol, TypeAlias
|
|
11
|
+
|
|
12
|
+
from adaptorch_client import (
|
|
13
|
+
AdaptOrchAPIError,
|
|
14
|
+
AdaptOrchClient,
|
|
15
|
+
ClientConfig,
|
|
16
|
+
ProviderCredential,
|
|
17
|
+
validate_api_url,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from adaptorch_cli import config_store
|
|
21
|
+
from adaptorch_cli.parser import build_parser, parse_args
|
|
22
|
+
|
|
23
|
+
JSONValue: TypeAlias = bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] | None
|
|
24
|
+
JSONMapping: TypeAlias = dict[str, JSONValue]
|
|
25
|
+
|
|
26
|
+
_MAX_SUBMIT_BYTES = 8 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PayloadResult(Protocol):
|
|
30
|
+
def to_payload(self) -> JSONMapping: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_json(payload: JSONMapping) -> None:
|
|
34
|
+
print(json.dumps(payload, allow_nan=False, sort_keys=True, separators=(",", ":")))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _reject_json_constant(_value: str) -> Never:
|
|
38
|
+
raise ValueError("non-finite JSON number")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _finite_float(text: str) -> float:
|
|
42
|
+
value = float(text)
|
|
43
|
+
if not math.isfinite(value):
|
|
44
|
+
raise ValueError("non-finite JSON number")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validated_api_url(api_url: str, parser: argparse.ArgumentParser) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return validate_api_url(api_url)
|
|
51
|
+
except ValueError:
|
|
52
|
+
parser.error(
|
|
53
|
+
"invalid --api-url: expected an https:// origin (or exact loopback http://)"
|
|
54
|
+
" without credentials, path, query, or fragment"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_submit_bytes(file_name: str, parser: argparse.ArgumentParser) -> bytes:
|
|
59
|
+
try:
|
|
60
|
+
if file_name == "-":
|
|
61
|
+
return sys.stdin.buffer.read(_MAX_SUBMIT_BYTES + 1)
|
|
62
|
+
with Path(file_name).open("rb") as stream:
|
|
63
|
+
return stream.read(_MAX_SUBMIT_BYTES + 1)
|
|
64
|
+
except OSError:
|
|
65
|
+
parser.error("submit input must be a readable JSON object")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_submit_payload(file_name: str, parser: argparse.ArgumentParser) -> JSONMapping:
|
|
69
|
+
raw = _read_submit_bytes(file_name, parser)
|
|
70
|
+
if len(raw) > _MAX_SUBMIT_BYTES:
|
|
71
|
+
parser.error("submit input exceeds the 8 MiB limit")
|
|
72
|
+
try:
|
|
73
|
+
value: JSONValue = json.loads(
|
|
74
|
+
raw.decode("utf-8"),
|
|
75
|
+
parse_constant=_reject_json_constant,
|
|
76
|
+
parse_float=_finite_float,
|
|
77
|
+
)
|
|
78
|
+
# pi-lens-ignore: unreachable-except
|
|
79
|
+
except RecursionError:
|
|
80
|
+
parser.error("submit input JSON is too deeply nested")
|
|
81
|
+
except ValueError:
|
|
82
|
+
parser.error("submit input must be a readable JSON object")
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
parser.error("submit input must be a JSON object")
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _require_client(api_url: str) -> AdaptOrchClient:
|
|
89
|
+
api_key, _source = config_store.resolve_api_key()
|
|
90
|
+
if not api_key:
|
|
91
|
+
print(
|
|
92
|
+
"authentication required: run `adaptorchctl auth login` or set ADAPTORCH_API_KEY",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
raise SystemExit(3)
|
|
96
|
+
return AdaptOrchClient(ClientConfig(api_url=api_url, api_key=api_key))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _submission_credential() -> ProviderCredential | None:
|
|
100
|
+
resolved, _source = config_store.resolve_provider_credential()
|
|
101
|
+
if resolved is None:
|
|
102
|
+
return None
|
|
103
|
+
provider, model, key = resolved
|
|
104
|
+
return ProviderCredential(provider, model, key)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_secret(prompt: str, *, stdin_flag: bool) -> str:
|
|
108
|
+
"""Read a secret from stdin (piped or --value-stdin) or a hidden prompt.
|
|
109
|
+
|
|
110
|
+
Never echoed: values on argv are rejected upstream by _CREDENTIAL_FLAGS.
|
|
111
|
+
"""
|
|
112
|
+
if stdin_flag or not sys.stdin.isatty():
|
|
113
|
+
value = sys.stdin.read().strip()
|
|
114
|
+
else:
|
|
115
|
+
import getpass
|
|
116
|
+
|
|
117
|
+
value = getpass.getpass(prompt).strip()
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _result_payload(result: PayloadResult) -> JSONMapping:
|
|
122
|
+
return result.to_payload()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _auth_command(
|
|
126
|
+
args: argparse.Namespace,
|
|
127
|
+
api_url: str,
|
|
128
|
+
parser: argparse.ArgumentParser,
|
|
129
|
+
) -> JSONMapping:
|
|
130
|
+
sub: str = args.auth_command
|
|
131
|
+
if sub == "status":
|
|
132
|
+
api_key, source = config_store.resolve_api_key()
|
|
133
|
+
return {
|
|
134
|
+
"authenticated": api_key is not None,
|
|
135
|
+
"credential_source": source,
|
|
136
|
+
"api_url": api_url,
|
|
137
|
+
}
|
|
138
|
+
if sub == "logout":
|
|
139
|
+
removed = config_store.clear_credentials()
|
|
140
|
+
return {"logged_out": True, "removed_stored_key": removed}
|
|
141
|
+
if sub == "login":
|
|
142
|
+
login_url = getattr(args, "login_api_url", None) or api_url
|
|
143
|
+
try:
|
|
144
|
+
login_url = validate_api_url(login_url)
|
|
145
|
+
except ValueError:
|
|
146
|
+
parser.error(
|
|
147
|
+
"invalid --api-url: expected an https:// origin (or exact loopback http://)"
|
|
148
|
+
)
|
|
149
|
+
key = _read_secret("AdaptOrch API key: ", stdin_flag=False)
|
|
150
|
+
if not key:
|
|
151
|
+
parser.error("no API key provided on stdin or at the prompt")
|
|
152
|
+
verified: bool | None = None
|
|
153
|
+
if not args.no_verify:
|
|
154
|
+
try:
|
|
155
|
+
whoami = AdaptOrchClient(ClientConfig(api_url=login_url, api_key=key)).whoami()
|
|
156
|
+
verified = True
|
|
157
|
+
_ = whoami
|
|
158
|
+
except AdaptOrchAPIError as error:
|
|
159
|
+
print(
|
|
160
|
+
f"login verification failed (HTTP {error.status_code}); "
|
|
161
|
+
"key not saved. Retry with --no-verify to store anyway.",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
raise SystemExit(3) from error
|
|
165
|
+
config_store.store_credentials(api_key=key, api_url=login_url)
|
|
166
|
+
return {
|
|
167
|
+
"logged_in": True,
|
|
168
|
+
"api_url": login_url,
|
|
169
|
+
"verified": bool(verified),
|
|
170
|
+
"config_path": str(config_store.config_path()),
|
|
171
|
+
}
|
|
172
|
+
parser.error("unknown auth command")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _config_command(
|
|
176
|
+
args: argparse.Namespace,
|
|
177
|
+
api_url: str,
|
|
178
|
+
parser: argparse.ArgumentParser,
|
|
179
|
+
) -> JSONMapping:
|
|
180
|
+
sub: str = args.config_command
|
|
181
|
+
if sub == "get":
|
|
182
|
+
view = config_store.config_view()
|
|
183
|
+
view["api_url"] = api_url
|
|
184
|
+
return view
|
|
185
|
+
if sub == "set":
|
|
186
|
+
key: str = args.key
|
|
187
|
+
if key not in config_store.configurable_keys():
|
|
188
|
+
parser.error(
|
|
189
|
+
"unknown config key; expected one of: "
|
|
190
|
+
+ ", ".join(config_store.configurable_keys())
|
|
191
|
+
)
|
|
192
|
+
value = getattr(args, "value", None)
|
|
193
|
+
if config_store.is_secret_key(key):
|
|
194
|
+
if value is not None:
|
|
195
|
+
parser.error(f"{key} is a secret; pass it via --value-stdin or a piped stdin")
|
|
196
|
+
value = _read_secret("Value: ", stdin_flag=args.value_stdin)
|
|
197
|
+
elif args.value_stdin:
|
|
198
|
+
value = sys.stdin.read().strip()
|
|
199
|
+
elif value is None:
|
|
200
|
+
parser.error("config set requires a value (or --value-stdin)")
|
|
201
|
+
if key == "api_url":
|
|
202
|
+
try:
|
|
203
|
+
value = validate_api_url(value)
|
|
204
|
+
except ValueError:
|
|
205
|
+
parser.error(
|
|
206
|
+
"invalid api_url: expected an https:// origin (or exact loopback http://)"
|
|
207
|
+
)
|
|
208
|
+
config_store.set_config_value(key, value)
|
|
209
|
+
return {"set": key, "config_path": str(config_store.config_path())}
|
|
210
|
+
if sub == "unset":
|
|
211
|
+
removed = config_store.unset_config_value(args.key)
|
|
212
|
+
return {"unset": args.key, "removed": removed}
|
|
213
|
+
parser.error("unknown config command")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_command(
|
|
217
|
+
args: argparse.Namespace,
|
|
218
|
+
api_url: str,
|
|
219
|
+
parser: argparse.ArgumentParser,
|
|
220
|
+
) -> tuple[JSONMapping, bool]:
|
|
221
|
+
command: str = args.run_command
|
|
222
|
+
match command:
|
|
223
|
+
case "submit":
|
|
224
|
+
file_name: str = args.file
|
|
225
|
+
request_id: str | None = args.request_id
|
|
226
|
+
payload = _read_submit_payload(file_name, parser)
|
|
227
|
+
submit_result = _require_client(api_url).submit_run(
|
|
228
|
+
payload,
|
|
229
|
+
idempotency_key=request_id or str(uuid.uuid4()),
|
|
230
|
+
provider_credential=_submission_credential(),
|
|
231
|
+
)
|
|
232
|
+
return _result_payload(submit_result), False
|
|
233
|
+
case "list":
|
|
234
|
+
status: str | None = args.status
|
|
235
|
+
project_id: str | None = args.project_id
|
|
236
|
+
list_result = _require_client(api_url).list_runs(
|
|
237
|
+
status=status,
|
|
238
|
+
project_id=project_id,
|
|
239
|
+
)
|
|
240
|
+
return _result_payload(list_result), False
|
|
241
|
+
case "get":
|
|
242
|
+
run_id: str = args.run_id
|
|
243
|
+
return _result_payload(_require_client(api_url).get_run(run_id)), True
|
|
244
|
+
case "cancel":
|
|
245
|
+
cancel_run_id: str = args.run_id
|
|
246
|
+
reason: str | None = args.reason
|
|
247
|
+
cancel_result = _require_client(api_url).cancel_run(
|
|
248
|
+
cancel_run_id,
|
|
249
|
+
reason=reason,
|
|
250
|
+
)
|
|
251
|
+
return _result_payload(cancel_result), False
|
|
252
|
+
case _:
|
|
253
|
+
parser.error("unknown run command")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _execute(
|
|
257
|
+
args: argparse.Namespace,
|
|
258
|
+
api_url: str,
|
|
259
|
+
parser: argparse.ArgumentParser,
|
|
260
|
+
) -> tuple[JSONMapping, bool]:
|
|
261
|
+
command: str = args.command
|
|
262
|
+
match command:
|
|
263
|
+
case "auth":
|
|
264
|
+
return _auth_command(args, api_url, parser), False
|
|
265
|
+
case "config":
|
|
266
|
+
return _config_command(args, api_url, parser), False
|
|
267
|
+
case "whoami":
|
|
268
|
+
return _result_payload(_require_client(api_url).whoami()), False
|
|
269
|
+
case "capabilities":
|
|
270
|
+
return _result_payload(_require_client(api_url).capabilities()), False
|
|
271
|
+
case "run":
|
|
272
|
+
return _run_command(args, api_url, parser)
|
|
273
|
+
case "evidence":
|
|
274
|
+
evidence_run_id: str = args.run_id
|
|
275
|
+
return _result_payload(_require_client(api_url).get_evidence(evidence_run_id)), False
|
|
276
|
+
case "artifact":
|
|
277
|
+
artifact_run_id: str = args.run_id
|
|
278
|
+
return _result_payload(_require_client(api_url).list_artifacts(artifact_run_id)), False
|
|
279
|
+
case _:
|
|
280
|
+
parser.error("unknown command")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _api_exit_code(error: AdaptOrchAPIError) -> int:
|
|
284
|
+
status_code = error.status_code
|
|
285
|
+
if status_code in {401, 403}:
|
|
286
|
+
return 3
|
|
287
|
+
if status_code == 404:
|
|
288
|
+
return 4
|
|
289
|
+
if status_code == 409:
|
|
290
|
+
return 5
|
|
291
|
+
if status_code == 429:
|
|
292
|
+
return 6
|
|
293
|
+
if status_code is None or 500 <= status_code <= 599:
|
|
294
|
+
return 7
|
|
295
|
+
return 10
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _payload_status(payload: JSONMapping) -> str:
|
|
299
|
+
status = payload.get("status")
|
|
300
|
+
if isinstance(status, str):
|
|
301
|
+
return status
|
|
302
|
+
data = payload.get("data")
|
|
303
|
+
if isinstance(data, dict):
|
|
304
|
+
nested = data.get("status")
|
|
305
|
+
if isinstance(nested, str):
|
|
306
|
+
return nested
|
|
307
|
+
return ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _run_status_exit(payload: JSONMapping) -> int:
|
|
311
|
+
normalized = _payload_status(payload).lower()
|
|
312
|
+
if normalized == "failed":
|
|
313
|
+
return 8
|
|
314
|
+
if normalized == "cancelled":
|
|
315
|
+
return 9
|
|
316
|
+
if normalized == "inconclusive":
|
|
317
|
+
return 10
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
322
|
+
parser = build_parser()
|
|
323
|
+
args = parse_args(argv)
|
|
324
|
+
flag_url = getattr(args, "api_url", None)
|
|
325
|
+
if flag_url is not None and not flag_url.strip():
|
|
326
|
+
parser.error("invalid --api-url: expected an https:// origin (or exact loopback http://)")
|
|
327
|
+
resolved_url, _url_source = config_store.resolve_api_url(flag_url)
|
|
328
|
+
api_url = _validated_api_url(resolved_url, parser)
|
|
329
|
+
try:
|
|
330
|
+
payload, check_run_status = _execute(args, api_url, parser)
|
|
331
|
+
try:
|
|
332
|
+
_write_json(payload)
|
|
333
|
+
except ValueError:
|
|
334
|
+
print("response contained non-encodable JSON values", file=sys.stderr)
|
|
335
|
+
return 7
|
|
336
|
+
return _run_status_exit(payload) if check_run_status else 0
|
|
337
|
+
# pi-lens-ignore: unreachable-except
|
|
338
|
+
except KeyboardInterrupt:
|
|
339
|
+
print("interrupted", file=sys.stderr)
|
|
340
|
+
return 130
|
|
341
|
+
# pi-lens-ignore: unreachable-except
|
|
342
|
+
except AdaptOrchAPIError as error:
|
|
343
|
+
code = _api_exit_code(error)
|
|
344
|
+
print(f"request failed (HTTP {error.status_code})", file=sys.stderr)
|
|
345
|
+
return code
|
|
346
|
+
except ValueError:
|
|
347
|
+
parser.error("invalid CLI input")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def entrypoint() -> None:
|
|
351
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Persisted CLI configuration (~/.config/adaptorch/config.json).
|
|
2
|
+
|
|
3
|
+
Railway-style credential store: `auth login` writes the tenant API key and the
|
|
4
|
+
resolved API URL here so every later command works without exporting
|
|
5
|
+
ADAPTORCH_API_KEY. `config set provider.*` wires a BYOK provider/model the same
|
|
6
|
+
way ADAPTORCH_PROVIDER* env vars do.
|
|
7
|
+
|
|
8
|
+
Precedence is always: environment variable > persisted config > built-in
|
|
9
|
+
default. Env wins so CI and one-shot overrides never fight stored state.
|
|
10
|
+
|
|
11
|
+
Secrets are stored as plain fields but the file is written mode 0600 and never
|
|
12
|
+
echoed back to stdout — `config get` and `auth status` mask them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
CONFIG_DIR_ENV = "ADAPTORCH_CONFIG_DIR"
|
|
23
|
+
DEFAULT_API_URL = "https://adaptorch.com"
|
|
24
|
+
|
|
25
|
+
# user-facing key -> stored leaf under the "provider" object
|
|
26
|
+
_PROVIDER_KEY_MAP = {
|
|
27
|
+
"provider.name": "provider",
|
|
28
|
+
"provider.model": "model",
|
|
29
|
+
"provider.api_key": "api_key",
|
|
30
|
+
}
|
|
31
|
+
_CONFIGURABLE_KEYS = frozenset({"api_url", *_PROVIDER_KEY_MAP})
|
|
32
|
+
|
|
33
|
+
# Keys whose values must never travel on argv or appear in stdout.
|
|
34
|
+
_SECRET_KEYS = frozenset({"provider.api_key"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_dir() -> Path:
|
|
38
|
+
override = os.environ.get(CONFIG_DIR_ENV)
|
|
39
|
+
if override and override.strip():
|
|
40
|
+
return Path(override.strip()).expanduser()
|
|
41
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
42
|
+
if xdg and xdg.strip():
|
|
43
|
+
return Path(xdg.strip()).expanduser() / "adaptorch"
|
|
44
|
+
return Path.home() / ".config" / "adaptorch"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_path() -> Path:
|
|
48
|
+
return config_dir() / "config.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config() -> dict[str, Any]:
|
|
52
|
+
path = config_path()
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return {}
|
|
57
|
+
except (OSError, json.JSONDecodeError):
|
|
58
|
+
return {}
|
|
59
|
+
return raw if isinstance(raw, dict) else {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
63
|
+
path = config_path()
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
tmp = path.with_suffix(".tmp")
|
|
66
|
+
tmp.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
67
|
+
os.chmod(tmp, 0o600)
|
|
68
|
+
tmp.replace(path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def configurable_keys() -> tuple[str, ...]:
|
|
72
|
+
return tuple(sorted(_CONFIGURABLE_KEYS))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_secret_key(key: str) -> bool:
|
|
76
|
+
return key in _SECRET_KEYS
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_config_value(key: str) -> str | None:
|
|
80
|
+
config = load_config()
|
|
81
|
+
if key == "api_url":
|
|
82
|
+
value = config.get("api_url")
|
|
83
|
+
elif key in _PROVIDER_KEY_MAP:
|
|
84
|
+
provider = config.get("provider")
|
|
85
|
+
leaf = _PROVIDER_KEY_MAP[key]
|
|
86
|
+
value = provider.get(leaf) if isinstance(provider, dict) else None
|
|
87
|
+
else:
|
|
88
|
+
return None
|
|
89
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_config_value(key: str, value: str) -> None:
|
|
93
|
+
if key not in _CONFIGURABLE_KEYS:
|
|
94
|
+
raise KeyError(key)
|
|
95
|
+
config = load_config()
|
|
96
|
+
if key == "api_url":
|
|
97
|
+
config["api_url"] = value
|
|
98
|
+
else:
|
|
99
|
+
provider = config.setdefault("provider", {})
|
|
100
|
+
if not isinstance(provider, dict):
|
|
101
|
+
provider = {}
|
|
102
|
+
config["provider"] = provider
|
|
103
|
+
provider[_PROVIDER_KEY_MAP[key]] = value
|
|
104
|
+
save_config(config)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unset_config_value(key: str) -> bool:
|
|
108
|
+
config = load_config()
|
|
109
|
+
removed = False
|
|
110
|
+
if key == "api_url":
|
|
111
|
+
removed = "api_url" in config
|
|
112
|
+
config.pop("api_url", None)
|
|
113
|
+
elif key == "api_key":
|
|
114
|
+
removed = "api_key" in config
|
|
115
|
+
config.pop("api_key", None)
|
|
116
|
+
elif key == "provider":
|
|
117
|
+
removed = "provider" in config
|
|
118
|
+
config.pop("provider", None)
|
|
119
|
+
elif key in _PROVIDER_KEY_MAP:
|
|
120
|
+
provider = config.get("provider")
|
|
121
|
+
leaf = _PROVIDER_KEY_MAP[key]
|
|
122
|
+
if isinstance(provider, dict) and leaf in provider:
|
|
123
|
+
provider.pop(leaf, None)
|
|
124
|
+
removed = True
|
|
125
|
+
if isinstance(provider, dict) and not provider:
|
|
126
|
+
config.pop("provider", None)
|
|
127
|
+
else:
|
|
128
|
+
return False
|
|
129
|
+
if removed:
|
|
130
|
+
save_config(config)
|
|
131
|
+
return removed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def store_credentials(*, api_key: str, api_url: str) -> None:
|
|
135
|
+
config = load_config()
|
|
136
|
+
config["api_key"] = api_key
|
|
137
|
+
config["api_url"] = api_url
|
|
138
|
+
save_config(config)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clear_credentials() -> bool:
|
|
142
|
+
config = load_config()
|
|
143
|
+
if "api_key" not in config:
|
|
144
|
+
return False
|
|
145
|
+
config.pop("api_key", None)
|
|
146
|
+
save_config(config)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_api_key() -> tuple[str | None, str]:
|
|
151
|
+
env_value = os.environ.get("ADAPTORCH_API_KEY")
|
|
152
|
+
if env_value and env_value.strip():
|
|
153
|
+
return env_value.strip(), "env"
|
|
154
|
+
stored = load_config().get("api_key")
|
|
155
|
+
if isinstance(stored, str) and stored.strip():
|
|
156
|
+
return stored.strip(), "config"
|
|
157
|
+
return None, "none"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_api_url(flag_value: str | None) -> tuple[str, str]:
|
|
161
|
+
# Explicit --api-url wins over everything (same precedence the argparse
|
|
162
|
+
# default gave it before); then env, then stored config, then default.
|
|
163
|
+
if flag_value and flag_value.strip():
|
|
164
|
+
return flag_value.strip(), "flag"
|
|
165
|
+
env_value = os.environ.get("ADAPTORCH_API_URL")
|
|
166
|
+
if env_value and env_value.strip():
|
|
167
|
+
return env_value.strip(), "env"
|
|
168
|
+
stored = get_config_value("api_url")
|
|
169
|
+
if stored:
|
|
170
|
+
return stored, "config"
|
|
171
|
+
return DEFAULT_API_URL, "default"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve_provider_credential() -> tuple[tuple[str, str, str] | None, str]:
|
|
175
|
+
env_provider = os.environ.get("ADAPTORCH_PROVIDER", "").strip()
|
|
176
|
+
env_model = os.environ.get("ADAPTORCH_PROVIDER_MODEL", "").strip()
|
|
177
|
+
env_key = os.environ.get("ADAPTORCH_PROVIDER_API_KEY", "").strip()
|
|
178
|
+
if env_provider or env_model or env_key:
|
|
179
|
+
return (env_provider, env_model, env_key), "env"
|
|
180
|
+
provider_cfg = load_config().get("provider")
|
|
181
|
+
if isinstance(provider_cfg, dict):
|
|
182
|
+
provider = str(provider_cfg.get("provider") or "").strip()
|
|
183
|
+
model = str(provider_cfg.get("model") or "").strip()
|
|
184
|
+
key = str(provider_cfg.get("api_key") or "").strip()
|
|
185
|
+
if provider or model or key:
|
|
186
|
+
return (provider, model, key), "config"
|
|
187
|
+
return None, "none"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def mask_secret(value: str | None) -> str | None:
|
|
191
|
+
if not value:
|
|
192
|
+
return None
|
|
193
|
+
if len(value) <= 8:
|
|
194
|
+
return "****"
|
|
195
|
+
return f"{value[:4]}…{value[-4:]}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def config_view() -> dict[str, Any]:
|
|
199
|
+
config = load_config()
|
|
200
|
+
provider_raw = config.get("provider")
|
|
201
|
+
provider: dict[str, Any] = provider_raw if isinstance(provider_raw, dict) else {}
|
|
202
|
+
stored_key = config.get("api_key")
|
|
203
|
+
provider_key = provider.get("api_key")
|
|
204
|
+
return {
|
|
205
|
+
"api_url": config.get("api_url"),
|
|
206
|
+
"api_key": mask_secret(stored_key if isinstance(stored_key, str) else None),
|
|
207
|
+
"credential_source": resolve_api_key()[1],
|
|
208
|
+
"provider": {
|
|
209
|
+
"name": provider.get("provider"),
|
|
210
|
+
"model": provider.get("model"),
|
|
211
|
+
"api_key": mask_secret(provider_key if isinstance(provider_key, str) else None),
|
|
212
|
+
"source": resolve_provider_credential()[1],
|
|
213
|
+
},
|
|
214
|
+
"config_path": str(config_path()),
|
|
215
|
+
}
|
adaptorch_cli/parser.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
_DEFAULT_API_URL = "https://adaptorch.com"
|
|
9
|
+
_CREDENTIAL_FLAGS = ("--token", "--api-key")
|
|
10
|
+
_IDEMPOTENCY_KEY_RE = re.compile(
|
|
11
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _idempotency_key(value: str) -> str:
|
|
16
|
+
if not _IDEMPOTENCY_KEY_RE.fullmatch(value):
|
|
17
|
+
raise argparse.ArgumentTypeError("request ID must be a hyphenated UUID")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _add_run_commands(parent: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
22
|
+
run = parent.add_parser("run", help="Manage runs")
|
|
23
|
+
commands = run.add_subparsers(dest="run_command", required=True)
|
|
24
|
+
|
|
25
|
+
submit = commands.add_parser("submit", help="Submit a run")
|
|
26
|
+
submit.add_argument("--file", required=True, help="JSON request path, or - for stdin")
|
|
27
|
+
submit.add_argument("--request-id", type=_idempotency_key, help="UUID idempotency key")
|
|
28
|
+
|
|
29
|
+
list_parser = commands.add_parser("list", help="List runs")
|
|
30
|
+
list_parser.add_argument("--status")
|
|
31
|
+
list_parser.add_argument("--project-id")
|
|
32
|
+
|
|
33
|
+
get = commands.add_parser("get", help="Get a run")
|
|
34
|
+
get.add_argument("run_id")
|
|
35
|
+
|
|
36
|
+
cancel = commands.add_parser("cancel", help="Cancel a run")
|
|
37
|
+
cancel.add_argument("run_id")
|
|
38
|
+
cancel.add_argument("--reason")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="adaptorchctl")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--api-url",
|
|
45
|
+
default=None,
|
|
46
|
+
help=(
|
|
47
|
+
"Control-plane origin. Resolution order: this flag, "
|
|
48
|
+
"ADAPTORCH_API_URL, the stored config (auth login), then "
|
|
49
|
+
f"{_DEFAULT_API_URL}."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("--output", choices=("json",), default="json")
|
|
53
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
54
|
+
|
|
55
|
+
auth = commands.add_parser("auth", help="Manage authentication")
|
|
56
|
+
auth_commands = auth.add_subparsers(dest="auth_command", required=True)
|
|
57
|
+
auth_commands.add_parser("status", help="Show the effective credential source")
|
|
58
|
+
login = auth_commands.add_parser(
|
|
59
|
+
"login",
|
|
60
|
+
help="Store a tenant API key (reads it from the prompt or piped stdin)",
|
|
61
|
+
)
|
|
62
|
+
login.add_argument(
|
|
63
|
+
"--api-url",
|
|
64
|
+
dest="login_api_url",
|
|
65
|
+
help="Control-plane origin to persist (default: current --api-url resolution)",
|
|
66
|
+
)
|
|
67
|
+
login.add_argument(
|
|
68
|
+
"--no-verify",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="Skip the whoami check before saving (offline login)",
|
|
71
|
+
)
|
|
72
|
+
auth_commands.add_parser("logout", help="Remove the stored API key")
|
|
73
|
+
|
|
74
|
+
config = commands.add_parser("config", help="Inspect and update configuration")
|
|
75
|
+
config_commands = config.add_subparsers(dest="config_command", required=True)
|
|
76
|
+
config_commands.add_parser("get", help="Show the merged configuration (secrets masked)")
|
|
77
|
+
config_set = config_commands.add_parser(
|
|
78
|
+
"set",
|
|
79
|
+
help="Set api_url, provider.name, provider.model, or provider.api_key",
|
|
80
|
+
)
|
|
81
|
+
config_set.add_argument("key")
|
|
82
|
+
config_set.add_argument("value", nargs="?")
|
|
83
|
+
config_set.add_argument(
|
|
84
|
+
"--value-stdin",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="Read the value from stdin (required for secrets)",
|
|
87
|
+
)
|
|
88
|
+
config_unset = config_commands.add_parser(
|
|
89
|
+
"unset",
|
|
90
|
+
help="Remove api_url, api_key, provider, or a provider.* field",
|
|
91
|
+
)
|
|
92
|
+
config_unset.add_argument("key")
|
|
93
|
+
|
|
94
|
+
commands.add_parser("whoami", help="Show the authenticated identity")
|
|
95
|
+
commands.add_parser("capabilities", help="Show server capabilities")
|
|
96
|
+
_add_run_commands(commands)
|
|
97
|
+
|
|
98
|
+
evidence = commands.add_parser("evidence", help="Inspect run evidence")
|
|
99
|
+
show = evidence.add_subparsers(dest="evidence_command", required=True).add_parser("show")
|
|
100
|
+
show.add_argument("run_id")
|
|
101
|
+
|
|
102
|
+
artifact = commands.add_parser("artifact", help="Inspect run artifacts")
|
|
103
|
+
list_artifacts = artifact.add_subparsers(dest="artifact_command", required=True).add_parser(
|
|
104
|
+
"list"
|
|
105
|
+
)
|
|
106
|
+
list_artifacts.add_argument("run_id")
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
|
|
111
|
+
arguments = list(argv) if argv is not None else None
|
|
112
|
+
inspected = arguments if arguments is not None else sys.argv[1:]
|
|
113
|
+
if any(argument.startswith(flag) for argument in inspected for flag in _CREDENTIAL_FLAGS):
|
|
114
|
+
build_parser().error("credential flags are not supported; use ADAPTORCH_API_KEY")
|
|
115
|
+
return build_parser().parse_args(arguments)
|
adaptorch_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adaptorch-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for AdaptOrch
|
|
5
|
+
Author: ClassicMate
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://adaptorch.com
|
|
8
|
+
Project-URL: Repository, https://github.com/dmae97/Adaptorch-MCP
|
|
9
|
+
Project-URL: Issues, https://github.com/dmae97/Adaptorch-MCP/issues
|
|
10
|
+
Project-URL: Documentation, https://adaptorch.com/mcp-docs
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: adaptorch-client<0.2,>=0.1
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# adaptorch-cli
|
|
18
|
+
|
|
19
|
+
Independent AdaptOrch SaaS CLI. Installs the `adaptorchctl` command and uses `adaptorch-client`; it does not invoke `adaptorch` or `adaptorch-mcp`.
|
|
20
|
+
|
|
21
|
+
Log in once and every command picks the credential up:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
adaptorchctl auth login # prompts for the key, verifies it, stores it at
|
|
25
|
+
# ~/.config/adaptorch/config.json (mode 0600)
|
|
26
|
+
adaptorchctl auth status # shows credential_source: env | config | none
|
|
27
|
+
adaptorchctl auth logout # removes the stored key
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`ADAPTORCH_API_KEY` still works and always wins over the stored key, so CI
|
|
31
|
+
keeps injecting secrets via the environment. `config set provider.{name,model,
|
|
32
|
+
api_key}` persists BYOK wiring for `run submit`; the env vars
|
|
33
|
+
`ADAPTORCH_PROVIDER`, `ADAPTORCH_PROVIDER_MODEL`, and
|
|
34
|
+
`ADAPTORCH_PROVIDER_API_KEY` override the stored values. Provider credentials
|
|
35
|
+
become `X-Provider`, `X-Provider-Model`, and `X-Provider-Key` only on
|
|
36
|
+
`run submit`; reads and cancellation never receive the provider key.
|
|
37
|
+
`ADAPTORCH_API_KEY` remains the separate tenant key.
|
|
38
|
+
|
|
39
|
+
Install the current `adaptorch-client` and `adaptorch-cli` source packages together.
|
|
40
|
+
A git push does not replace a previously published PyPI version.
|
|
41
|
+
|
|
42
|
+
See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for usage.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
adaptorch_cli/__init__.py,sha256=TdTlC9AgxqPXk9r6DF65q5DKHe2JMT_4gl1UKRuyvZU,37
|
|
2
|
+
adaptorch_cli/__main__.py,sha256=FZ_NGpkbOJoyt0KrNSfGfIqz0fw3PFe7VAJzFIrfq64,55
|
|
3
|
+
adaptorch_cli/cli.py,sha256=2jUQ0jZ5S9N3oFsrf-CfzR6WJYM4BM40OgnfIqAEyUQ,11897
|
|
4
|
+
adaptorch_cli/config_store.py,sha256=P__8yl5-WXrAiDsO21M_8OGvKiPh-FpdMl6TdBs_kPo,7075
|
|
5
|
+
adaptorch_cli/parser.py,sha256=nHpY_Yj9hvYKG9J0i5sPoiAX0bqHmD04a5ObE6xwiCg,4520
|
|
6
|
+
adaptorch_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
adaptorch_cli-0.1.0.dist-info/licenses/LICENSE,sha256=nTFgpQO0Lh5wFPjLzTIu-GnUVAFLlmdI6mLUkXjivpE,783
|
|
8
|
+
adaptorch_cli-0.1.0.dist-info/METADATA,sha256=fjxt0S-9rap01HeH0Ce5-DEa2EGQ0ihkDjQ2KmEtI1k,2000
|
|
9
|
+
adaptorch_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
adaptorch_cli-0.1.0.dist-info/entry_points.txt,sha256=5GY-UW4_v5YKxWvhWuxcM3Z1EGWlVFQ38Y4QoqseWa8,62
|
|
11
|
+
adaptorch_cli-0.1.0.dist-info/top_level.txt,sha256=zUfCzlUONIzyiRRRi6CYq1D1r_0FNfEFRmIKa51Jgwg,14
|
|
12
|
+
adaptorch_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Copyright (c) 2026 ClassicMate. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are proprietary
|
|
4
|
+
and confidential. Unauthorized copying, distribution, modification, public
|
|
5
|
+
display, or any use of the Software other than as expressly authorized by ClassicMate
|
|
6
|
+
is strictly prohibited.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
9
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
10
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
11
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
12
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
13
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
14
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
adaptorch_cli
|