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.
@@ -0,0 +1 @@
1
+ """AdaptOrch command-line client."""
@@ -0,0 +1,3 @@
1
+ from adaptorch_cli.cli import entrypoint
2
+
3
+ entrypoint()
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
+ }
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adaptorchctl = adaptorch_cli.cli:entrypoint
@@ -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