sonilo-cli 0.10.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.10.0 → sonilo_cli-0.11.0}/PKG-INFO +55 -4
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/README.md +53 -2
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/pyproject.toml +1 -1
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/src/sonilo_cli/__main__.py +62 -4
- 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.10.0 → sonilo_cli-0.11.0}/tests/test_cli.py +105 -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.10.0 → sonilo_cli-0.11.0}/.gitignore +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/LICENSE +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.11.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.10.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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -577,6 +600,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
577
600
|
_add_global(parser)
|
|
578
601
|
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
579
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
|
+
|
|
580
630
|
p_account = sub.add_parser("account", help="Show plan limits and available services")
|
|
581
631
|
_add_global(p_account)
|
|
582
632
|
p_account.set_defaults(func=cmd_account)
|
|
@@ -789,6 +839,14 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
789
839
|
func = getattr(args, "func", None)
|
|
790
840
|
if func is None:
|
|
791
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
|
|
792
850
|
client = build_client(getattr(args, "api_key", None))
|
|
793
851
|
try:
|
|
794
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)
|