sonilo-cli 0.9.0__tar.gz → 0.11.0__tar.gz
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.
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/PKG-INFO +69 -5
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/README.md +66 -2
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/pyproject.toml +2 -2
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/src/sonilo_cli/__main__.py +82 -5
- sonilo_cli-0.11.0/src/sonilo_cli/credentials.py +105 -0
- sonilo_cli-0.11.0/src/sonilo_cli/login.py +347 -0
- sonilo_cli-0.11.0/tests/conftest.py +19 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/tests/test_cli.py +166 -0
- sonilo_cli-0.11.0/tests/test_credentials.py +146 -0
- sonilo_cli-0.11.0/tests/test_login.py +366 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/.gitignore +0 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/LICENSE +0 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.9.0 → sonilo_cli-0.11.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sonilo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
|
|
5
5
|
Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
|
|
6
6
|
Author: Sonilo AI
|
|
@@ -8,7 +8,7 @@ License-Expression: MIT
|
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
|
-
Requires-Dist: sonilo<0.
|
|
11
|
+
Requires-Dist: sonilo<0.13,>=0.12.0
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
14
|
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
@@ -22,16 +22,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
|
|
|
22
22
|
|
|
23
23
|
pip install sonilo-cli
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Signing in
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Run this once per machine — there is no key to create, paste, or export:
|
|
28
|
+
|
|
29
|
+
sonilo login
|
|
30
|
+
|
|
31
|
+
It prints a one-time code and opens your browser to platform.sonilo.com. Sign
|
|
32
|
+
in, confirm the code matches what the terminal printed, and approve; the CLI is
|
|
33
|
+
waiting on that page and continues by itself. Every command works from then on.
|
|
34
|
+
|
|
35
|
+
Approving mints an ordinary Sonilo API key on your account, named
|
|
36
|
+
`cli: <hostname>` and valid for **90 days**, stored in
|
|
37
|
+
`~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
|
|
38
|
+
set), owner-readable only. It is visible and revocable at
|
|
39
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
|
|
40
|
+
key.
|
|
41
|
+
|
|
42
|
+
sonilo whoami # which account, key prefix, expiry, and which source is active
|
|
43
|
+
sonilo logout # revoke the key server-side, then forget it locally
|
|
44
|
+
|
|
45
|
+
`logout` revokes before forgetting, so a machine that loses the file never
|
|
46
|
+
leaves a live key behind. If the revoke cannot reach the API the credential is
|
|
47
|
+
deliberately kept and you are pointed at the dashboard, rather than being left
|
|
48
|
+
holding a key nothing can revoke.
|
|
49
|
+
|
|
50
|
+
Signing in again while already signed in reports the existing session; use
|
|
51
|
+
`--force` to replace it (that mints a fresh key and revokes the one it
|
|
52
|
+
replaces). When a credential has expired you do not need the flag — `sonilo
|
|
53
|
+
login` treats an expired sign-in as no sign-in. On a machine with no browser,
|
|
54
|
+
add `--no-browser` and approve the printed URL from another device.
|
|
55
|
+
|
|
56
|
+
The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
|
|
57
|
+
read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
|
|
58
|
+
sign-in covers all three — an MCP host config then needs no `env` block at all.
|
|
59
|
+
|
|
60
|
+
## Auth with an API key
|
|
61
|
+
|
|
62
|
+
Signing in is optional. A key from
|
|
63
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
|
|
64
|
+
it always has, which is what you want for CI, containers, and anything
|
|
65
|
+
non-interactive — there is no browser there to approve with, and a 90-day expiry
|
|
66
|
+
is not something a pipeline should depend on:
|
|
28
67
|
|
|
29
68
|
export SONILO_API_KEY=sk-...
|
|
30
69
|
|
|
31
70
|
or pass `--api-key sk-...` on any command.
|
|
32
71
|
|
|
72
|
+
Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
|
|
73
|
+
then the stored sign-in. That order is a guarantee, not an accident — an
|
|
74
|
+
exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
|
|
75
|
+
login` on the same machine cannot quietly move your calls to another account.
|
|
76
|
+
|
|
77
|
+
Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
|
|
78
|
+
environment. Credentials are stored per host, so a staging sign-in and a
|
|
79
|
+
production sign-in coexist without overwriting each other.
|
|
80
|
+
|
|
33
81
|
## Commands
|
|
34
82
|
|
|
83
|
+
sonilo login # sign in with your browser, no key needed
|
|
84
|
+
sonilo whoami # show the active account and credential source
|
|
85
|
+
sonilo logout # revoke the stored key and forget it
|
|
35
86
|
sonilo account # plan limits and available services
|
|
36
87
|
sonilo usage --days 7 # usage summary
|
|
37
88
|
sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
|
|
@@ -106,6 +157,17 @@ request — and values above 1 are never covered by the free trial.
|
|
|
106
157
|
- On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
|
|
107
158
|
`take.0.music.m4a`.
|
|
108
159
|
|
|
160
|
+
### Prompt influence
|
|
161
|
+
|
|
162
|
+
`--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
|
|
163
|
+
prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
|
|
164
|
+
higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
|
|
165
|
+
does not force the async path — it works on the streaming default too. Left unset, the field is
|
|
166
|
+
not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
|
|
167
|
+
("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
|
|
168
|
+
|
|
169
|
+
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
|
|
170
|
+
|
|
109
171
|
### Scored video
|
|
110
172
|
|
|
111
173
|
`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
|
|
@@ -128,6 +190,8 @@ file (default `output.mp4`):
|
|
|
128
190
|
- For music *and* effects in one call, use `video-to-video-sound` below.
|
|
129
191
|
- `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
|
|
130
192
|
`video-to-video-sfx` does not.
|
|
193
|
+
- `video-to-video-music` also takes `--prompt-influence` — see
|
|
194
|
+
[Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
|
|
131
195
|
|
|
132
196
|
### Combined soundtracks
|
|
133
197
|
|
|
@@ -6,16 +6,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
|
|
|
6
6
|
|
|
7
7
|
pip install sonilo-cli
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Signing in
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Run this once per machine — there is no key to create, paste, or export:
|
|
12
|
+
|
|
13
|
+
sonilo login
|
|
14
|
+
|
|
15
|
+
It prints a one-time code and opens your browser to platform.sonilo.com. Sign
|
|
16
|
+
in, confirm the code matches what the terminal printed, and approve; the CLI is
|
|
17
|
+
waiting on that page and continues by itself. Every command works from then on.
|
|
18
|
+
|
|
19
|
+
Approving mints an ordinary Sonilo API key on your account, named
|
|
20
|
+
`cli: <hostname>` and valid for **90 days**, stored in
|
|
21
|
+
`~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
|
|
22
|
+
set), owner-readable only. It is visible and revocable at
|
|
23
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
|
|
24
|
+
key.
|
|
25
|
+
|
|
26
|
+
sonilo whoami # which account, key prefix, expiry, and which source is active
|
|
27
|
+
sonilo logout # revoke the key server-side, then forget it locally
|
|
28
|
+
|
|
29
|
+
`logout` revokes before forgetting, so a machine that loses the file never
|
|
30
|
+
leaves a live key behind. If the revoke cannot reach the API the credential is
|
|
31
|
+
deliberately kept and you are pointed at the dashboard, rather than being left
|
|
32
|
+
holding a key nothing can revoke.
|
|
33
|
+
|
|
34
|
+
Signing in again while already signed in reports the existing session; use
|
|
35
|
+
`--force` to replace it (that mints a fresh key and revokes the one it
|
|
36
|
+
replaces). When a credential has expired you do not need the flag — `sonilo
|
|
37
|
+
login` treats an expired sign-in as no sign-in. On a machine with no browser,
|
|
38
|
+
add `--no-browser` and approve the printed URL from another device.
|
|
39
|
+
|
|
40
|
+
The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
|
|
41
|
+
read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
|
|
42
|
+
sign-in covers all three — an MCP host config then needs no `env` block at all.
|
|
43
|
+
|
|
44
|
+
## Auth with an API key
|
|
45
|
+
|
|
46
|
+
Signing in is optional. A key from
|
|
47
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
|
|
48
|
+
it always has, which is what you want for CI, containers, and anything
|
|
49
|
+
non-interactive — there is no browser there to approve with, and a 90-day expiry
|
|
50
|
+
is not something a pipeline should depend on:
|
|
12
51
|
|
|
13
52
|
export SONILO_API_KEY=sk-...
|
|
14
53
|
|
|
15
54
|
or pass `--api-key sk-...` on any command.
|
|
16
55
|
|
|
56
|
+
Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
|
|
57
|
+
then the stored sign-in. That order is a guarantee, not an accident — an
|
|
58
|
+
exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
|
|
59
|
+
login` on the same machine cannot quietly move your calls to another account.
|
|
60
|
+
|
|
61
|
+
Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
|
|
62
|
+
environment. Credentials are stored per host, so a staging sign-in and a
|
|
63
|
+
production sign-in coexist without overwriting each other.
|
|
64
|
+
|
|
17
65
|
## Commands
|
|
18
66
|
|
|
67
|
+
sonilo login # sign in with your browser, no key needed
|
|
68
|
+
sonilo whoami # show the active account and credential source
|
|
69
|
+
sonilo logout # revoke the stored key and forget it
|
|
19
70
|
sonilo account # plan limits and available services
|
|
20
71
|
sonilo usage --days 7 # usage summary
|
|
21
72
|
sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
|
|
@@ -90,6 +141,17 @@ request — and values above 1 are never covered by the free trial.
|
|
|
90
141
|
- On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
|
|
91
142
|
`take.0.music.m4a`.
|
|
92
143
|
|
|
144
|
+
### Prompt influence
|
|
145
|
+
|
|
146
|
+
`--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the
|
|
147
|
+
prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead;
|
|
148
|
+
higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it
|
|
149
|
+
does not force the async path — it works on the streaming default too. Left unset, the field is
|
|
150
|
+
not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value
|
|
151
|
+
("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API.
|
|
152
|
+
|
|
153
|
+
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8
|
|
154
|
+
|
|
93
155
|
### Scored video
|
|
94
156
|
|
|
95
157
|
`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
|
|
@@ -112,6 +174,8 @@ file (default `output.mp4`):
|
|
|
112
174
|
- For music *and* effects in one call, use `video-to-video-sound` below.
|
|
113
175
|
- `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
|
|
114
176
|
`video-to-video-sfx` does not.
|
|
177
|
+
- `video-to-video-music` also takes `--prompt-influence` — see
|
|
178
|
+
[Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not.
|
|
115
179
|
|
|
116
180
|
### Combined soundtracks
|
|
117
181
|
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sonilo-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
authors = [{ name = "Sonilo AI" }]
|
|
13
|
-
dependencies = ["sonilo>=0.
|
|
13
|
+
dependencies = ["sonilo>=0.12.0,<0.13"]
|
|
14
14
|
keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
@@ -12,7 +12,8 @@ from urllib.parse import urlparse
|
|
|
12
12
|
from sonilo import Sonilo
|
|
13
13
|
from sonilo.errors import APIError, SoniloError
|
|
14
14
|
|
|
15
|
-
from sonilo_cli import __version__
|
|
15
|
+
from sonilo_cli import __version__, credentials
|
|
16
|
+
from sonilo_cli.login import LoginError, cmd_login, cmd_logout, cmd_whoami
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class _Parser(argparse.ArgumentParser):
|
|
@@ -196,12 +197,34 @@ def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
|
|
|
196
197
|
return parse_segments(args.segments, args.segments_shape, args.command)
|
|
197
198
|
|
|
198
199
|
|
|
199
|
-
def
|
|
200
|
+
def _expired(iso: str) -> bool:
|
|
201
|
+
"""Whether a stored credential's expiry has passed.
|
|
202
|
+
|
|
203
|
+
Reuses login's parser so both paths agree on what "expired" means, and on
|
|
204
|
+
treating an unparseable timestamp as *not* expired — the API is the
|
|
205
|
+
authority on whether a key works, and refusing to run over a formatting
|
|
206
|
+
quirk would be worse than letting a dead key earn its own 401.
|
|
207
|
+
"""
|
|
208
|
+
from sonilo_cli.login import _is_expired
|
|
209
|
+
|
|
210
|
+
return _is_expired(iso)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_client(api_key: Optional[str], path: Optional[Path] = None) -> Sonilo:
|
|
214
|
+
api_base = os.environ.get("SONILO_API_URL", credentials.DEFAULT_API_BASE).rstrip("/")
|
|
215
|
+
# Order is a compatibility promise: an exported SONILO_API_KEY keeps winning
|
|
216
|
+
# over a stored credential, so upgrading never moves someone's account.
|
|
200
217
|
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
218
|
+
if not key:
|
|
219
|
+
stored = credentials.read_credential(api_base, path)
|
|
220
|
+
if stored is not None:
|
|
221
|
+
if _expired(stored.get("expires_at", "")):
|
|
222
|
+
_fail('your sonilo login expired — run "sonilo login" again')
|
|
223
|
+
key = stored["api_key"]
|
|
201
224
|
if not key:
|
|
202
225
|
_fail(
|
|
203
|
-
|
|
204
|
-
"SONILO_API_KEY environment variable"
|
|
226
|
+
'no API key — run "sonilo login", or pass --api-key <key>, or set '
|
|
227
|
+
"the SONILO_API_KEY environment variable"
|
|
205
228
|
)
|
|
206
229
|
# Identify as the CLI rather than inheriting the SDK's own name, so CLI
|
|
207
230
|
# traffic stays separable from direct SDK use in server-side analytics.
|
|
@@ -309,12 +332,15 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
309
332
|
preserve_speech=args.preserve_speech or None,
|
|
310
333
|
output_format=fmt if fmt != "m4a" else None,
|
|
311
334
|
variants_num=args.variants,
|
|
335
|
+
prompt_influence=args.prompt_influence,
|
|
312
336
|
)
|
|
313
337
|
_save_music_variants(result, out)
|
|
314
338
|
else:
|
|
339
|
+
# prompt_influence rides the streaming path too — it is a generation
|
|
340
|
+
# parameter, not a finalize-time one, so it never forces async.
|
|
315
341
|
track = client.video_to_music.generate(
|
|
316
342
|
video=args.video, video_url=args.video_url, prompt=args.prompt,
|
|
317
|
-
segments=segments,
|
|
343
|
+
segments=segments, prompt_influence=args.prompt_influence,
|
|
318
344
|
)
|
|
319
345
|
path = track.save(out)
|
|
320
346
|
_wrote(path, len(track.audio))
|
|
@@ -435,6 +461,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
435
461
|
preserve_speech=True if args.preserve_speech else None,
|
|
436
462
|
isolate_vocals=True if args.isolate_vocals else None,
|
|
437
463
|
variants_num=args.variants,
|
|
464
|
+
prompt_influence=args.prompt_influence,
|
|
438
465
|
)
|
|
439
466
|
|
|
440
467
|
|
|
@@ -543,6 +570,19 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
|
|
|
543
570
|
parser.set_defaults(segments_shape=shape)
|
|
544
571
|
|
|
545
572
|
|
|
573
|
+
def _add_prompt_influence(parser: argparse.ArgumentParser) -> None:
|
|
574
|
+
# Only the two music-from-video commands take this — the API accepts it
|
|
575
|
+
# nowhere else. type=float so 0 arrives as 0.0, a real value ("let the
|
|
576
|
+
# video lead entirely"), distinct from the unset None that keeps the
|
|
577
|
+
# field off the wire and leaves the API its own 0.5 default.
|
|
578
|
+
parser.add_argument(
|
|
579
|
+
"--prompt-influence", dest="prompt_influence", type=float, default=None,
|
|
580
|
+
help="How strongly the generated music follows the prompt, 0-1 "
|
|
581
|
+
"(API default 0.5). Lower values let the video lead; higher "
|
|
582
|
+
"values follow the prompt more literally. Free of charge.",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
546
586
|
def _add_variants(parser: argparse.ArgumentParser) -> None:
|
|
547
587
|
parser.add_argument(
|
|
548
588
|
"--variants", type=int, default=None,
|
|
@@ -560,6 +600,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
560
600
|
_add_global(parser)
|
|
561
601
|
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
562
602
|
|
|
603
|
+
# login/logout/whoami run *before* a client exists — build_client exits when
|
|
604
|
+
# no key is available, which is the exact situation login is for. The
|
|
605
|
+
# needs_client=False flag is what main() checks to skip that.
|
|
606
|
+
p_login = sub.add_parser("login", help="Sign in and store an API key for future commands")
|
|
607
|
+
_add_global(p_login)
|
|
608
|
+
p_login.add_argument("--force", action="store_true",
|
|
609
|
+
help="Sign in again even if already signed in, replacing the stored key.")
|
|
610
|
+
p_login.add_argument("--no-browser", action="store_true", dest="no_browser",
|
|
611
|
+
help="Print the URL instead of opening a browser.")
|
|
612
|
+
p_login.add_argument("--api-base", dest="api_base", default=None,
|
|
613
|
+
help="Sign in against a non-default API base URL.")
|
|
614
|
+
p_login.set_defaults(func=lambda client, args: cmd_login(args), needs_client=False)
|
|
615
|
+
|
|
616
|
+
p_logout = sub.add_parser("logout", help="Revoke the stored key and forget it locally")
|
|
617
|
+
_add_global(p_logout)
|
|
618
|
+
p_logout.add_argument("--local-only", action="store_true", dest="local_only",
|
|
619
|
+
help="Forget the credential without revoking the key server-side.")
|
|
620
|
+
p_logout.add_argument("--api-base", dest="api_base", default=None,
|
|
621
|
+
help="Act on the credential for a non-default API base URL.")
|
|
622
|
+
p_logout.set_defaults(func=lambda client, args: cmd_logout(args), needs_client=False)
|
|
623
|
+
|
|
624
|
+
p_whoami = sub.add_parser("whoami", help="Show which account and key are currently active")
|
|
625
|
+
_add_global(p_whoami)
|
|
626
|
+
p_whoami.add_argument("--api-base", dest="api_base", default=None,
|
|
627
|
+
help="Inspect the credential for a non-default API base URL.")
|
|
628
|
+
p_whoami.set_defaults(func=lambda client, args: cmd_whoami(args), needs_client=False)
|
|
629
|
+
|
|
563
630
|
p_account = sub.add_parser("account", help="Show plan limits and available services")
|
|
564
631
|
_add_global(p_account)
|
|
565
632
|
p_account.set_defaults(func=cmd_account)
|
|
@@ -601,6 +668,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
601
668
|
p_v2m.add_argument("--async", dest="use_async", action="store_true",
|
|
602
669
|
help="Submit and poll instead of streaming.")
|
|
603
670
|
_add_variants(p_v2m)
|
|
671
|
+
_add_prompt_influence(p_v2m)
|
|
604
672
|
p_v2m.set_defaults(func=cmd_video_to_music)
|
|
605
673
|
|
|
606
674
|
p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
|
|
@@ -677,6 +745,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
677
745
|
help="Legacy alias for --preserve-speech; no separate stem.")
|
|
678
746
|
p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
|
|
679
747
|
_add_variants(p_v2vm)
|
|
748
|
+
_add_prompt_influence(p_v2vm)
|
|
680
749
|
p_v2vm.set_defaults(func=cmd_video_to_video_music)
|
|
681
750
|
|
|
682
751
|
p_v2vfx = sub.add_parser(
|
|
@@ -770,6 +839,14 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
770
839
|
func = getattr(args, "func", None)
|
|
771
840
|
if func is None:
|
|
772
841
|
parser.error("missing command (try `sonilo --help`)")
|
|
842
|
+
if getattr(args, "needs_client", True) is False:
|
|
843
|
+
try:
|
|
844
|
+
func(None, args) # type: ignore[arg-type]
|
|
845
|
+
except LoginError as exc:
|
|
846
|
+
# Expected outcomes — denied, expired, throttled, offline — read as
|
|
847
|
+
# `sonilo: <message>`, never as a traceback.
|
|
848
|
+
_fail(str(exc))
|
|
849
|
+
return
|
|
773
850
|
client = build_client(getattr(args, "api_key", None))
|
|
774
851
|
try:
|
|
775
852
|
func(client, args)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The credential file written by `sonilo login`.
|
|
2
|
+
|
|
3
|
+
Format and location are shared with the JS CLI (`packages/cli/src/credentials.ts`
|
|
4
|
+
in sonilo-js) and read by sonilo-mcp, so the path, the JSON shape and the
|
|
5
|
+
0600/0700 modes are a cross-repo contract — change them in all three or not at
|
|
6
|
+
all.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_BASE = "https://api.sonilo.com"
|
|
16
|
+
FORMAT_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CredentialFormatError(Exception):
|
|
20
|
+
"""The file was written by a newer client than this one understands."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_path() -> Path:
|
|
24
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
25
|
+
root = Path(base) if base else Path(os.environ.get("HOME", str(Path.home()))) / ".config"
|
|
26
|
+
return root / "sonilo" / "credentials.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _empty() -> Dict[str, Any]:
|
|
30
|
+
return {"version": FORMAT_VERSION, "credentials": {}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(path: Path) -> Dict[str, Any]:
|
|
34
|
+
try:
|
|
35
|
+
raw = path.read_text(encoding="utf-8")
|
|
36
|
+
except OSError:
|
|
37
|
+
return _empty()
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(raw)
|
|
40
|
+
except ValueError:
|
|
41
|
+
# Corrupt file reads as "no credential"; `login` will replace it.
|
|
42
|
+
return _empty()
|
|
43
|
+
if not isinstance(parsed, dict):
|
|
44
|
+
return _empty()
|
|
45
|
+
version = parsed.get("version")
|
|
46
|
+
if isinstance(version, int) and version > FORMAT_VERSION:
|
|
47
|
+
raise CredentialFormatError(
|
|
48
|
+
f"{path} was written by a newer sonilo CLI (format {version}). "
|
|
49
|
+
"Upgrade the CLI or delete the file."
|
|
50
|
+
)
|
|
51
|
+
creds = parsed.get("credentials")
|
|
52
|
+
return {
|
|
53
|
+
"version": FORMAT_VERSION,
|
|
54
|
+
"credentials": creds if isinstance(creds, dict) else {},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _save(path: Path, data: Dict[str, Any]) -> None:
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
os.chmod(path.parent, 0o700)
|
|
61
|
+
tmp = path.with_name(path.name + ".{}.tmp".format(os.getpid()))
|
|
62
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
63
|
+
os.chmod(tmp, 0o600)
|
|
64
|
+
try:
|
|
65
|
+
# Rename last: an interrupted write can never replace a whole
|
|
66
|
+
# credential with half of one.
|
|
67
|
+
os.replace(tmp, path)
|
|
68
|
+
except OSError:
|
|
69
|
+
# Never leave the temp file behind holding a live key.
|
|
70
|
+
try:
|
|
71
|
+
tmp.unlink()
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_credential(api_base: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
|
|
78
|
+
entry = _load(path or credentials_path())["credentials"].get(api_base)
|
|
79
|
+
if isinstance(entry, dict) and isinstance(entry.get("api_key"), str) and entry["api_key"]:
|
|
80
|
+
return entry
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_credential(
|
|
85
|
+
api_base: str, cred: Dict[str, Any], path: Optional[Path] = None
|
|
86
|
+
) -> None:
|
|
87
|
+
target = path or credentials_path()
|
|
88
|
+
data = _load(target)
|
|
89
|
+
data["credentials"][api_base] = cred
|
|
90
|
+
_save(target, data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_credential(api_base: str, path: Optional[Path] = None) -> None:
|
|
94
|
+
target = path or credentials_path()
|
|
95
|
+
data = _load(target)
|
|
96
|
+
if api_base not in data["credentials"]:
|
|
97
|
+
return
|
|
98
|
+
del data["credentials"][api_base]
|
|
99
|
+
if not data["credentials"]:
|
|
100
|
+
try:
|
|
101
|
+
target.unlink()
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
return
|
|
105
|
+
_save(target, data)
|