upscaler-cli 0.2.2.dev6560__py3-none-any.whl → 0.2.2.dev6601__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.
- src/auth/token_store.py +17 -4
- src/cli/auth.py +32 -27
- src/cli/automation.py +221 -0
- src/cli/config_cmd.py +15 -9
- src/cli/context.py +3 -0
- src/cli/framework.py +408 -0
- src/cli/helpers.py +55 -2
- src/cli/main.py +40 -7
- src/cli/profile_cmd.py +120 -0
- src/config.py +47 -12
- src/profile.py +124 -0
- {upscaler_cli-0.2.2.dev6560.dist-info → upscaler_cli-0.2.2.dev6601.dist-info}/METADATA +1 -1
- {upscaler_cli-0.2.2.dev6560.dist-info → upscaler_cli-0.2.2.dev6601.dist-info}/RECORD +16 -12
- {upscaler_cli-0.2.2.dev6560.dist-info → upscaler_cli-0.2.2.dev6601.dist-info}/WHEEL +0 -0
- {upscaler_cli-0.2.2.dev6560.dist-info → upscaler_cli-0.2.2.dev6601.dist-info}/entry_points.txt +0 -0
- {upscaler_cli-0.2.2.dev6560.dist-info → upscaler_cli-0.2.2.dev6601.dist-info}/top_level.txt +0 -0
src/auth/token_store.py
CHANGED
|
@@ -15,6 +15,7 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Optional
|
|
16
16
|
|
|
17
17
|
from src.auth.encryption import decrypt, derive_key, encrypt
|
|
18
|
+
from src.profile import ensure_profile_dir, get_profile_dir
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@dataclass
|
|
@@ -31,14 +32,26 @@ class TokenData:
|
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class TokenStore:
|
|
34
|
-
"""Encrypted token store at ~/.upscaler/."""
|
|
35
|
+
"""Encrypted token store at ~/.upscaler/profiles/{profile}/."""
|
|
35
36
|
|
|
36
37
|
SALT_FILE = ".salt"
|
|
37
38
|
TOKEN_FILE = "tokens.enc"
|
|
38
39
|
SALT_SIZE = 16
|
|
39
40
|
|
|
40
|
-
def __init__(
|
|
41
|
-
self
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config_dir: Optional[str] = None,
|
|
44
|
+
profile: Optional[str] = None,
|
|
45
|
+
):
|
|
46
|
+
"""Open the token store for `profile` (or the resolved active profile).
|
|
47
|
+
|
|
48
|
+
Passing `config_dir` overrides profile resolution and points at an
|
|
49
|
+
explicit directory; used by tests.
|
|
50
|
+
"""
|
|
51
|
+
if config_dir is not None:
|
|
52
|
+
self.config_dir = Path(config_dir)
|
|
53
|
+
else:
|
|
54
|
+
self.config_dir = get_profile_dir(profile)
|
|
42
55
|
self._cached_key: Optional[bytes] = None
|
|
43
56
|
|
|
44
57
|
def _get_key(self) -> bytes:
|
|
@@ -54,7 +67,7 @@ class TokenStore:
|
|
|
54
67
|
Creates config dir (0o700), salt file (0o600) if needed,
|
|
55
68
|
writes to .tmp then renames for atomicity.
|
|
56
69
|
"""
|
|
57
|
-
self.config_dir
|
|
70
|
+
ensure_profile_dir(self.config_dir)
|
|
58
71
|
|
|
59
72
|
key = self._get_key()
|
|
60
73
|
|
src/cli/auth.py
CHANGED
|
@@ -114,7 +114,7 @@ def _login_browser(ctx, port):
|
|
|
114
114
|
from src.auth.token_store import TokenStore
|
|
115
115
|
from src.config import CLIConfig
|
|
116
116
|
|
|
117
|
-
config = CLIConfig()
|
|
117
|
+
config = CLIConfig(profile=ctx.profile)
|
|
118
118
|
server_url = config.resolve_server_url(ctx.server_url)
|
|
119
119
|
if not server_url:
|
|
120
120
|
click.echo(
|
|
@@ -125,7 +125,7 @@ def _login_browser(ctx, port):
|
|
|
125
125
|
|
|
126
126
|
verify_ssl = config.resolve_verify_ssl()
|
|
127
127
|
flow = OAuthFlow(server_url, verify_ssl=verify_ssl)
|
|
128
|
-
store = TokenStore()
|
|
128
|
+
store = TokenStore(profile=ctx.profile)
|
|
129
129
|
|
|
130
130
|
try:
|
|
131
131
|
if not ctx.json_mode:
|
|
@@ -152,7 +152,7 @@ def _login_device(ctx):
|
|
|
152
152
|
from src.auth.oauth import OAuthFlow
|
|
153
153
|
from src.config import CLIConfig
|
|
154
154
|
|
|
155
|
-
config = CLIConfig()
|
|
155
|
+
config = CLIConfig(profile=ctx.profile)
|
|
156
156
|
server_url = config.resolve_server_url(ctx.server_url)
|
|
157
157
|
if not server_url:
|
|
158
158
|
click.echo(
|
|
@@ -174,7 +174,7 @@ def _login_device(ctx):
|
|
|
174
174
|
sys.exit(1)
|
|
175
175
|
|
|
176
176
|
# Save pending device code for --check
|
|
177
|
-
_save_pending_device(pending)
|
|
177
|
+
_save_pending_device(pending, ctx.profile)
|
|
178
178
|
|
|
179
179
|
if ctx.json_mode:
|
|
180
180
|
click.echo(json.dumps({
|
|
@@ -203,7 +203,7 @@ def _login_check(ctx):
|
|
|
203
203
|
from src.auth.token_store import TokenStore
|
|
204
204
|
from src.config import CLIConfig
|
|
205
205
|
|
|
206
|
-
pending = _load_pending_device()
|
|
206
|
+
pending = _load_pending_device(ctx.profile)
|
|
207
207
|
if not pending:
|
|
208
208
|
msg = "No pending device authorization. Run: upscaler login --no-browser"
|
|
209
209
|
if ctx.json_mode:
|
|
@@ -214,7 +214,7 @@ def _login_check(ctx):
|
|
|
214
214
|
|
|
215
215
|
# Check expiry
|
|
216
216
|
if time.time() >= pending["expires_at"]:
|
|
217
|
-
_delete_pending_device()
|
|
217
|
+
_delete_pending_device(ctx.profile)
|
|
218
218
|
msg = "Device code expired. Run: upscaler login --no-browser"
|
|
219
219
|
if ctx.json_mode:
|
|
220
220
|
click.echo(json.dumps({"error": msg, "expired": True}), err=True)
|
|
@@ -222,7 +222,7 @@ def _login_check(ctx):
|
|
|
222
222
|
click.echo(msg, err=True)
|
|
223
223
|
sys.exit(2)
|
|
224
224
|
|
|
225
|
-
config = CLIConfig()
|
|
225
|
+
config = CLIConfig(profile=ctx.profile)
|
|
226
226
|
server_url = pending.get("server_url") or config.resolve_server_url(ctx.server_url)
|
|
227
227
|
verify_ssl = config.resolve_verify_ssl()
|
|
228
228
|
flow = OAuthFlow(server_url, verify_ssl=verify_ssl)
|
|
@@ -245,7 +245,7 @@ def _login_check(ctx):
|
|
|
245
245
|
)
|
|
246
246
|
sys.exit(1)
|
|
247
247
|
except RuntimeError as e:
|
|
248
|
-
_delete_pending_device()
|
|
248
|
+
_delete_pending_device(ctx.profile)
|
|
249
249
|
if ctx.json_mode:
|
|
250
250
|
click.echo(json.dumps({"error": str(e)}), err=True)
|
|
251
251
|
else:
|
|
@@ -253,9 +253,9 @@ def _login_check(ctx):
|
|
|
253
253
|
sys.exit(2)
|
|
254
254
|
|
|
255
255
|
# Success — save tokens and clean up pending file
|
|
256
|
-
store = TokenStore()
|
|
256
|
+
store = TokenStore(profile=ctx.profile)
|
|
257
257
|
store.save(token_data)
|
|
258
|
-
_delete_pending_device()
|
|
258
|
+
_delete_pending_device(ctx.profile)
|
|
259
259
|
|
|
260
260
|
if ctx.json_mode:
|
|
261
261
|
expires_in = int(token_data.expires_at - time.time())
|
|
@@ -265,36 +265,41 @@ def _login_check(ctx):
|
|
|
265
265
|
|
|
266
266
|
|
|
267
267
|
# ---------------------------------------------------------------------------
|
|
268
|
-
# Pending device code persistence (~/.upscaler/pending_device.json)
|
|
268
|
+
# Pending device code persistence (~/.upscaler/profiles/{profile}/pending_device.json)
|
|
269
269
|
# ---------------------------------------------------------------------------
|
|
270
270
|
|
|
271
271
|
_PENDING_FILE = "pending_device.json"
|
|
272
272
|
|
|
273
273
|
|
|
274
|
-
def _get_pending_path():
|
|
275
|
-
"""Get path to the pending device code file."""
|
|
274
|
+
def _get_pending_path(profile: str | None = None) -> str:
|
|
275
|
+
"""Get path to the pending device code file for the active profile."""
|
|
276
276
|
import os
|
|
277
277
|
|
|
278
|
-
|
|
279
|
-
return os.path.join(config_dir, _PENDING_FILE)
|
|
278
|
+
from src.profile import get_profile_dir
|
|
280
279
|
|
|
280
|
+
config_dir = get_profile_dir(profile)
|
|
281
|
+
return os.path.join(str(config_dir), _PENDING_FILE)
|
|
281
282
|
|
|
282
|
-
|
|
283
|
+
|
|
284
|
+
def _save_pending_device(data: dict, profile: str | None = None) -> None:
|
|
283
285
|
"""Save pending device code to disk."""
|
|
284
286
|
import os
|
|
287
|
+
from pathlib import Path
|
|
288
|
+
|
|
289
|
+
from src.profile import ensure_profile_dir
|
|
285
290
|
|
|
286
|
-
path = _get_pending_path()
|
|
287
|
-
|
|
291
|
+
path = _get_pending_path(profile)
|
|
292
|
+
ensure_profile_dir(Path(os.path.dirname(path)))
|
|
288
293
|
with open(path, "w") as f:
|
|
289
294
|
f.write(json.dumps(data))
|
|
290
295
|
os.chmod(path, 0o600)
|
|
291
296
|
|
|
292
297
|
|
|
293
|
-
def _load_pending_device() -> dict | None:
|
|
298
|
+
def _load_pending_device(profile: str | None = None) -> dict | None:
|
|
294
299
|
"""Load pending device code from disk, or None if absent."""
|
|
295
300
|
import os
|
|
296
301
|
|
|
297
|
-
path = _get_pending_path()
|
|
302
|
+
path = _get_pending_path(profile)
|
|
298
303
|
if not os.path.exists(path):
|
|
299
304
|
return None
|
|
300
305
|
try:
|
|
@@ -304,11 +309,11 @@ def _load_pending_device() -> dict | None:
|
|
|
304
309
|
return None
|
|
305
310
|
|
|
306
311
|
|
|
307
|
-
def _delete_pending_device() -> None:
|
|
312
|
+
def _delete_pending_device(profile: str | None = None) -> None:
|
|
308
313
|
"""Remove the pending device code file."""
|
|
309
314
|
import os
|
|
310
315
|
|
|
311
|
-
path = _get_pending_path()
|
|
316
|
+
path = _get_pending_path(profile)
|
|
312
317
|
if os.path.exists(path):
|
|
313
318
|
os.unlink(path)
|
|
314
319
|
|
|
@@ -326,9 +331,9 @@ def refresh(ctx):
|
|
|
326
331
|
from src.auth.token_store import TokenStore
|
|
327
332
|
from src.config import CLIConfig
|
|
328
333
|
|
|
329
|
-
config = CLIConfig()
|
|
334
|
+
config = CLIConfig(profile=ctx.profile)
|
|
330
335
|
server_url = config.resolve_server_url(ctx.server_url)
|
|
331
|
-
store = TokenStore()
|
|
336
|
+
store = TokenStore(profile=ctx.profile)
|
|
332
337
|
|
|
333
338
|
try:
|
|
334
339
|
token_data = store.load()
|
|
@@ -370,7 +375,7 @@ def status(ctx):
|
|
|
370
375
|
"""
|
|
371
376
|
from src.auth.token_store import TokenStore
|
|
372
377
|
|
|
373
|
-
store = TokenStore()
|
|
378
|
+
store = TokenStore(profile=ctx.profile)
|
|
374
379
|
|
|
375
380
|
try:
|
|
376
381
|
token_data = store.load()
|
|
@@ -411,9 +416,9 @@ def logout(ctx):
|
|
|
411
416
|
from src.auth.token_store import TokenStore
|
|
412
417
|
from src.config import CLIConfig
|
|
413
418
|
|
|
414
|
-
config = CLIConfig()
|
|
419
|
+
config = CLIConfig(profile=ctx.profile)
|
|
415
420
|
server_url = config.resolve_server_url(ctx.server_url)
|
|
416
|
-
store = TokenStore()
|
|
421
|
+
store = TokenStore(profile=ctx.profile)
|
|
417
422
|
|
|
418
423
|
try:
|
|
419
424
|
token_data = store.load()
|
src/cli/automation.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Automation management CLI commands.
|
|
2
|
+
|
|
3
|
+
Lists, inspects, and manages automations (scheduled actions on assets/members).
|
|
4
|
+
Wraps POST /api/v1/automations on the up-ai REST API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from src.cli.context import pass_context
|
|
12
|
+
from src.cli.helpers import execute_rest_action
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group("automation")
|
|
16
|
+
def automation_group():
|
|
17
|
+
"""Manage automations: list, get, create, update, enable, disable, run, delete, runs.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
upscaler automation list
|
|
21
|
+
upscaler automation get auto_abc123
|
|
22
|
+
upscaler automation create --data @automation.json
|
|
23
|
+
upscaler automation enable auto_abc123
|
|
24
|
+
upscaler automation run auto_abc123
|
|
25
|
+
upscaler automation runs auto_abc123 --limit 50
|
|
26
|
+
upscaler automation list --asset-id d_xyz
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@automation_group.command("list")
|
|
32
|
+
@click.option("--search", default=None, help="Search filter.")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--action-type",
|
|
35
|
+
"action_types",
|
|
36
|
+
multiple=True,
|
|
37
|
+
help=(
|
|
38
|
+
"Filter by action type (repeatable): createTodo, createRecord, "
|
|
39
|
+
"sendEmailDigest, createReviewRequest"
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--asset-id",
|
|
44
|
+
default=None,
|
|
45
|
+
help="If given, list automations bound to this asset (uses getAssetAutomations).",
|
|
46
|
+
)
|
|
47
|
+
@click.option("--upcoming", is_flag=True, help="Show only upcoming automations.")
|
|
48
|
+
@click.option("--limit", default=50, type=int, help="Page size (default 50).")
|
|
49
|
+
@click.option("--offset", default=0, type=int, help="Page offset (default 0).")
|
|
50
|
+
@pass_context
|
|
51
|
+
def automation_list(ctx, search, action_types, asset_id, upcoming, limit, offset):
|
|
52
|
+
"""List automations."""
|
|
53
|
+
if upcoming and asset_id:
|
|
54
|
+
click.echo("--upcoming and --asset-id are mutually exclusive", err=True)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
if asset_id:
|
|
58
|
+
action = "list_for_asset"
|
|
59
|
+
payload = {"action": action, "asset_id": asset_id, "limit": limit, "offset": offset}
|
|
60
|
+
elif upcoming:
|
|
61
|
+
action = "list_upcoming"
|
|
62
|
+
payload = {"action": action, "limit": limit, "offset": offset}
|
|
63
|
+
else:
|
|
64
|
+
action = "list"
|
|
65
|
+
payload = {"action": action, "limit": limit, "offset": offset}
|
|
66
|
+
if search:
|
|
67
|
+
payload["search"] = search
|
|
68
|
+
if action_types:
|
|
69
|
+
payload["action_types"] = list(action_types)
|
|
70
|
+
|
|
71
|
+
_execute(ctx, payload)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@automation_group.command("get")
|
|
75
|
+
@click.argument("automation_id")
|
|
76
|
+
@pass_context
|
|
77
|
+
def automation_get(ctx, automation_id):
|
|
78
|
+
"""Fetch a single automation by ID."""
|
|
79
|
+
_execute(ctx, {"action": "get", "id": automation_id})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@automation_group.command("runs")
|
|
83
|
+
@click.argument("automation_id")
|
|
84
|
+
@click.option("--limit", default=20, type=int)
|
|
85
|
+
@click.option("--offset", default=0, type=int)
|
|
86
|
+
@pass_context
|
|
87
|
+
def automation_runs(ctx, automation_id, limit, offset):
|
|
88
|
+
"""List recent runs of an automation."""
|
|
89
|
+
_execute(
|
|
90
|
+
ctx,
|
|
91
|
+
{"action": "list_runs", "id": automation_id, "limit": limit, "offset": offset},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@automation_group.command("create")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--data",
|
|
98
|
+
"data_input",
|
|
99
|
+
required=True,
|
|
100
|
+
help="JSON payload (value, - for stdin, @file).",
|
|
101
|
+
)
|
|
102
|
+
@click.option("--dry-run", is_flag=True, help="Preview without creating.")
|
|
103
|
+
@pass_context
|
|
104
|
+
def automation_create(ctx, data_input, dry_run):
|
|
105
|
+
"""Create an automation.
|
|
106
|
+
|
|
107
|
+
--data should be CreateAutomationInput shape, e.g.:
|
|
108
|
+
{"title": "Weekly review", "trigger": {"type": "schedule",
|
|
109
|
+
"schedule": {"cron": "0 9 * * 1", "timezone": "UTC"}},
|
|
110
|
+
"target": {"type": "asset", "asset": {"id": "d_xxx", "type": "document"}},
|
|
111
|
+
"action": {"type": "createTodo", "createTodo":
|
|
112
|
+
{"title": "Review doc", "assignees": ["user_abc"]}}}
|
|
113
|
+
"""
|
|
114
|
+
from src.cli.helpers import parse_data
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
data = parse_data(data_input)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
click.echo(str(e), err=True)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
_execute(ctx, {"action": "create", "data": data}, dry_run=dry_run)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@automation_group.command("update")
|
|
126
|
+
@click.argument("automation_id")
|
|
127
|
+
@click.option("--data", "data_input", required=True, help="JSON payload.")
|
|
128
|
+
@click.option("--dry-run", is_flag=True)
|
|
129
|
+
@pass_context
|
|
130
|
+
def automation_update(ctx, automation_id, data_input, dry_run):
|
|
131
|
+
"""Update an automation."""
|
|
132
|
+
from src.cli.helpers import parse_data
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = parse_data(data_input)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
click.echo(str(e), err=True)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
_execute(ctx, {"action": "update", "id": automation_id, "data": data}, dry_run=dry_run)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@automation_group.command("enable")
|
|
144
|
+
@click.argument("automation_id")
|
|
145
|
+
@pass_context
|
|
146
|
+
def automation_enable(ctx, automation_id):
|
|
147
|
+
"""Enable an automation."""
|
|
148
|
+
_execute(ctx, {"action": "enable", "id": automation_id})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@automation_group.command("disable")
|
|
152
|
+
@click.argument("automation_id")
|
|
153
|
+
@pass_context
|
|
154
|
+
def automation_disable(ctx, automation_id):
|
|
155
|
+
"""Disable an automation."""
|
|
156
|
+
_execute(ctx, {"action": "disable", "id": automation_id})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@automation_group.command("run")
|
|
160
|
+
@click.argument("automation_id")
|
|
161
|
+
@pass_context
|
|
162
|
+
def automation_run(ctx, automation_id):
|
|
163
|
+
"""Manually trigger an automation now."""
|
|
164
|
+
_execute(ctx, {"action": "run", "id": automation_id})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@automation_group.command("delete")
|
|
168
|
+
@click.argument("automation_id")
|
|
169
|
+
@click.option("--dry-run", is_flag=True)
|
|
170
|
+
@pass_context
|
|
171
|
+
def automation_delete(ctx, automation_id, dry_run):
|
|
172
|
+
"""Delete an automation."""
|
|
173
|
+
from src.cli.helpers import confirm_destructive
|
|
174
|
+
|
|
175
|
+
if not dry_run and not confirm_destructive("delete", automation_id, ctx.json_mode):
|
|
176
|
+
click.echo("Cancelled.", err=True)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
_execute(ctx, {"action": "delete", "id": automation_id}, dry_run=dry_run)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _execute(ctx, payload, dry_run=False):
|
|
183
|
+
execute_rest_action(
|
|
184
|
+
ctx, payload, "/api/v1/automations", render=_render_human, dry_run=dry_run
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render_human(action, result):
|
|
189
|
+
from src.formatters.json_fmt import format_json
|
|
190
|
+
|
|
191
|
+
if not result.get("success"):
|
|
192
|
+
click.echo(format_json(result))
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
data = result.get("data")
|
|
196
|
+
meta = result.get("metadata") or {}
|
|
197
|
+
|
|
198
|
+
if isinstance(data, list):
|
|
199
|
+
if not data:
|
|
200
|
+
click.echo("(no results)")
|
|
201
|
+
return
|
|
202
|
+
for item in data:
|
|
203
|
+
if isinstance(item, dict):
|
|
204
|
+
if "triggeredAt" in item or "scheduleId" in item:
|
|
205
|
+
click.echo(
|
|
206
|
+
f"{item.get('triggeredAt', '')} {item.get('status', ''):8} "
|
|
207
|
+
f"{item.get('resultTitle', '') or item.get('id', '')}"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
enabled = "on " if item.get("enabled") else "off"
|
|
211
|
+
next_due = item.get("nextDue") or ""
|
|
212
|
+
click.echo(
|
|
213
|
+
f"{item.get('id', ''):28} [{enabled}] "
|
|
214
|
+
f"{item.get('title', '') or '(untitled)':40} next: {next_due}"
|
|
215
|
+
)
|
|
216
|
+
if meta.get("total_count") is not None:
|
|
217
|
+
click.echo(f"\nTotal: {meta['total_count']} (showing {len(data)})")
|
|
218
|
+
elif isinstance(data, dict):
|
|
219
|
+
click.echo(format_json(data))
|
|
220
|
+
else:
|
|
221
|
+
click.echo(str(data))
|
src/cli/config_cmd.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
"""CLI config command for persistent settings."""
|
|
1
|
+
"""CLI config command for persistent settings (per profile)."""
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
4
|
|
|
5
|
+
from src.cli.context import pass_context
|
|
5
6
|
from src.config import VALID_KEYS, CLIConfig
|
|
6
7
|
|
|
7
8
|
|
|
8
|
-
@click.group(
|
|
9
|
+
@click.group(
|
|
10
|
+
"config",
|
|
11
|
+
help="Manage persistent CLI settings in ~/.upscaler/profiles/{profile}/config.json.",
|
|
12
|
+
)
|
|
9
13
|
def config_group():
|
|
10
14
|
pass
|
|
11
15
|
|
|
@@ -13,12 +17,13 @@ def config_group():
|
|
|
13
17
|
@config_group.command("set")
|
|
14
18
|
@click.argument("key")
|
|
15
19
|
@click.argument("value")
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
@pass_context
|
|
21
|
+
def config_set(ctx, key, value):
|
|
22
|
+
"""Set a config value on the active profile."""
|
|
18
23
|
try:
|
|
19
|
-
config = CLIConfig()
|
|
24
|
+
config = CLIConfig(profile=ctx.profile)
|
|
20
25
|
config.set(key, value)
|
|
21
|
-
click.echo(f"{key} = {value}")
|
|
26
|
+
click.echo(f"[{ctx.profile}] {key} = {value}")
|
|
22
27
|
except ValueError as e:
|
|
23
28
|
click.echo(str(e), err=True)
|
|
24
29
|
raise SystemExit(1)
|
|
@@ -26,14 +31,15 @@ def config_set(key, value):
|
|
|
26
31
|
|
|
27
32
|
@config_group.command("get")
|
|
28
33
|
@click.argument("key")
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
@pass_context
|
|
35
|
+
def config_get(ctx, key):
|
|
36
|
+
"""Get a config value from the active profile."""
|
|
31
37
|
if key not in VALID_KEYS:
|
|
32
38
|
click.echo(
|
|
33
39
|
f"Unknown config key: {key}. Valid keys: {', '.join(sorted(VALID_KEYS))}",
|
|
34
40
|
err=True,
|
|
35
41
|
)
|
|
36
42
|
raise SystemExit(1)
|
|
37
|
-
config = CLIConfig()
|
|
43
|
+
config = CLIConfig(profile=ctx.profile)
|
|
38
44
|
value = config.get(key)
|
|
39
45
|
click.echo(value if value else "")
|
src/cli/context.py
CHANGED
|
@@ -4,6 +4,8 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
|
+
from src.profile import DEFAULT_PROFILE
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
class Context:
|
|
9
11
|
"""Shared context object passed to all commands."""
|
|
@@ -12,6 +14,7 @@ class Context:
|
|
|
12
14
|
self.json_mode: bool = False
|
|
13
15
|
self.verbose: bool = False
|
|
14
16
|
self.server_url: Optional[str] = None
|
|
17
|
+
self.profile: str = DEFAULT_PROFILE
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
pass_context = click.make_pass_decorator(Context, ensure=True)
|