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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sonilo-cli
3
- Version: 0.10.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
- ## Auth
25
+ ## Signing in
26
26
 
27
- Set your API key once:
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
- ## Auth
9
+ ## Signing in
10
10
 
11
- Set your API key once:
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.10.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"
@@ -1,3 +1,3 @@
1
- __version__ = "0.10.0"
1
+ __version__ = "0.11.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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 build_client(api_key: Optional[str]) -> Sonilo:
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
- "no API key — pass --api-key <key> or set the "
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)