vocalize-cli 0.3.0__tar.gz → 0.4.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.
Files changed (30) hide show
  1. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/CHANGELOG.md +6 -0
  2. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/PKG-INFO +34 -1
  3. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/README.md +33 -0
  4. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_cli.py +45 -0
  5. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_tts.py +46 -1
  6. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/__init__.py +1 -1
  7. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/cli.py +44 -1
  8. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/tts.py +19 -0
  9. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.env.example +0 -0
  10. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.github/workflows/ci.yml +0 -0
  11. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/.gitignore +0 -0
  12. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/LICENSE +0 -0
  13. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/hooks/claude_stop_hook.py +0 -0
  14. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/hooks/install_hook.py +0 -0
  15. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/pyproject.toml +0 -0
  16. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/conftest.py +0 -0
  17. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_audio.py +0 -0
  18. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_auth.py +0 -0
  19. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_claude_stop_hook.py +0 -0
  20. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_config.py +0 -0
  21. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_install_hook.py +0 -0
  22. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_preprocess.py +0 -0
  23. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/tests/test_wizard.py +0 -0
  24. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/__main__.py +0 -0
  25. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/audio.py +0 -0
  26. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/auth.py +0 -0
  27. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/config.py +0 -0
  28. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/exceptions.py +0 -0
  29. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/preprocess.py +0 -0
  30. {vocalize_cli-0.3.0 → vocalize_cli-0.4.0}/vocalize/wizard.py +0 -0
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## 0.4.0
7
+
8
+ ### Added
9
+
10
+ - `vocalize usage` — ElevenLabs quota and local cache at a glance.
11
+
6
12
  ## 0.3.0
7
13
 
8
14
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vocalize-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
5
5
  Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
6
6
  Project-URL: Repository, https://github.com/matthager12-collab/vocalize
@@ -139,6 +139,9 @@ cat notes.md | vocalize speak-file -
139
139
  # List available voices and grab an ID
140
140
  vocalize voices
141
141
 
142
+ # Check your quota and cache
143
+ vocalize usage
144
+
142
145
  # Use a specific voice/model, save without playing
143
146
  vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
144
147
  --output out.mp3 --no-play
@@ -258,6 +261,36 @@ run in Claude Code's own environment, not your interactive shell — if
258
261
  `VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
259
262
  point the hook at it directly.
260
263
 
264
+ ### Speaking files, artifacts, and more
265
+
266
+ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
267
+ any local file (markdown flattened first), and the hook's `--latest` mode
268
+ speaks the most recent Claude Code response. Anything Claude itself has to
269
+ fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
270
+ (the CLI has no session), summarized, and piped in:
271
+
272
+ ```bash
273
+ printf '%s' "the summary text" | vocalize speak-file -
274
+ ```
275
+
276
+ If you wire this into a slash command of your own, treat it as a security
277
+ surface, because **every character you speak is sent to ElevenLabs**. The
278
+ guard principles that matter, in order:
279
+
280
+ 1. Resolve paths (`realpath`, expand `~`, casefold) and check an
281
+ **allow-list** of speakable directories — symlinks and `../` defeat
282
+ string matching on the raw argument.
283
+ 2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
284
+ sensitive directories; confirm before speaking anything else unusual.
285
+ 3. Summarize long or fetched content in an **isolated subagent** that
286
+ returns only the summary — content you fetched can carry instructions
287
+ aimed at your session.
288
+ 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
289
+ 5. Confirm before any read that will spend real quota; a free tier is
290
+ 10,000 characters a month.
291
+ 6. Remember the disk cache: everything spoken leaves an mp3 under
292
+ `~/.cache/vocalize/`.
293
+
261
294
  ## How it's built
262
295
 
263
296
  Four decisions shaped the design:
@@ -105,6 +105,9 @@ cat notes.md | vocalize speak-file -
105
105
  # List available voices and grab an ID
106
106
  vocalize voices
107
107
 
108
+ # Check your quota and cache
109
+ vocalize usage
110
+
108
111
  # Use a specific voice/model, save without playing
109
112
  vocalize speak-file report.md --voice <voice-id> --model eleven_flash_v2_5 \
110
113
  --output out.mp3 --no-play
@@ -224,6 +227,36 @@ run in Claude Code's own environment, not your interactive shell — if
224
227
  `VOCALIZE_BIN` to the full path (e.g. `/path/to/.venv/bin/vocalize`) to
225
228
  point the hook at it directly.
226
229
 
230
+ ### Speaking files, artifacts, and more
231
+
232
+ Two primitives cover almost everything: `vocalize speak-file <path>` speaks
233
+ any local file (markdown flattened first), and the hook's `--latest` mode
234
+ speaks the most recent Claude Code response. Anything Claude itself has to
235
+ fetch — a claude.ai artifact, for instance — has to be fetched *by Claude*
236
+ (the CLI has no session), summarized, and piped in:
237
+
238
+ ```bash
239
+ printf '%s' "the summary text" | vocalize speak-file -
240
+ ```
241
+
242
+ If you wire this into a slash command of your own, treat it as a security
243
+ surface, because **every character you speak is sent to ElevenLabs**. The
244
+ guard principles that matter, in order:
245
+
246
+ 1. Resolve paths (`realpath`, expand `~`, casefold) and check an
247
+ **allow-list** of speakable directories — symlinks and `../` defeat
248
+ string matching on the raw argument.
249
+ 2. Hard-refuse secret-shaped files (`.env*`, keys, credentials) and your
250
+ sensitive directories; confirm before speaking anything else unusual.
251
+ 3. Summarize long or fetched content in an **isolated subagent** that
252
+ returns only the summary — content you fetched can carry instructions
253
+ aimed at your session.
254
+ 4. Pipe summaries over stdin (as above) — no temp files, nothing in argv.
255
+ 5. Confirm before any read that will spend real quota; a free tier is
256
+ 10,000 characters a month.
257
+ 6. Remember the disk cache: everything spoken leaves an mp3 under
258
+ `~/.cache/vocalize/`.
259
+
227
260
  ## How it's built
228
261
 
229
262
  Four decisions shaped the design:
@@ -255,6 +255,51 @@ def test_no_speed_flag_leaves_speed_unset(monkeypatch, tmp_path):
255
255
  assert captured_settings[0].speed is None
256
256
 
257
257
 
258
+ def test_usage_command_prints_tier_used_limit_and_percent(monkeypatch, tmp_path):
259
+ monkeypatch.setattr(cli_module, "build_client", lambda key: object())
260
+ monkeypatch.setattr(
261
+ cli_module,
262
+ "get_usage",
263
+ lambda client: {"tier": "creator", "used": 12345, "limit": 100000, "resets_at": None},
264
+ )
265
+ # Empty tmp_path also covers the "cache empty" branch.
266
+ monkeypatch.setattr(cli_module, "DEFAULT_CACHE_DIR", tmp_path)
267
+ runner = CliRunner()
268
+
269
+ result = runner.invoke(main, ["usage", "--api-key", "fake-key"])
270
+
271
+ assert result.exit_code == 0, result.output
272
+ assert "creator" in result.output
273
+ assert "12,345" in result.output
274
+ assert "100,000" in result.output
275
+ assert "12.3%" in result.output
276
+ assert "cache empty" in result.output
277
+
278
+
279
+ def test_usage_command_reports_local_cache_file_count(monkeypatch, tmp_path):
280
+ from datetime import datetime, timezone
281
+
282
+ reset_unix = 1735689600
283
+ expected_date = datetime.fromtimestamp(reset_unix, tz=timezone.utc).astimezone().strftime("%Y-%m-%d")
284
+ monkeypatch.setattr(cli_module, "build_client", lambda key: object())
285
+ monkeypatch.setattr(
286
+ cli_module,
287
+ "get_usage",
288
+ lambda client: {"tier": "free", "used": 0, "limit": 10000, "resets_at": reset_unix},
289
+ )
290
+ (tmp_path / "a.mp3").write_bytes(b"x" * 1000)
291
+ (tmp_path / "b.mp3").write_bytes(b"y" * 2000)
292
+ (tmp_path / "not-audio.txt").write_bytes(b"ignore me")
293
+ monkeypatch.setattr(cli_module, "DEFAULT_CACHE_DIR", tmp_path)
294
+ runner = CliRunner()
295
+
296
+ result = runner.invoke(main, ["usage", "--api-key", "fake-key"])
297
+
298
+ assert result.exit_code == 0, result.output
299
+ assert "2 files" in result.output
300
+ assert expected_date in result.output
301
+
302
+
258
303
  def test_invalid_speed_gives_a_clean_error_not_a_traceback(monkeypatch, tmp_path, capsys):
259
304
  _isolate_config(monkeypatch, tmp_path)
260
305
  monkeypatch.setattr(
@@ -6,7 +6,7 @@ import pytest
6
6
 
7
7
  from vocalize.config import Settings
8
8
  from vocalize.exceptions import TTSRequestError
9
- from vocalize.tts import _cache_key, list_voices, synthesize
9
+ from vocalize.tts import _cache_key, get_usage, list_voices, synthesize
10
10
 
11
11
 
12
12
  class FakeTTSNamespace:
@@ -30,6 +30,26 @@ class FakeVoicesNamespace:
30
30
  return SimpleNamespace(voices=self._voices)
31
31
 
32
32
 
33
+ class FakeUserNamespace:
34
+ def __init__(self, tier="free", used=1000, limit=10000, resets_at=1700000000, raise_error=None):
35
+ self._tier = tier
36
+ self._used = used
37
+ self._limit = limit
38
+ self._resets_at = resets_at
39
+ self._raise_error = raise_error
40
+ self.subscription = SimpleNamespace(get=self._get)
41
+
42
+ def _get(self):
43
+ if self._raise_error:
44
+ raise self._raise_error
45
+ return SimpleNamespace(
46
+ tier=self._tier,
47
+ character_count=self._used,
48
+ character_limit=self._limit,
49
+ next_character_count_reset_unix=self._resets_at,
50
+ )
51
+
52
+
33
53
  class FakeClient:
34
54
  def __init__(self, chunks=(b"fake", b"-audio"), raise_error=None, voices=None):
35
55
  self.text_to_speech = FakeTTSNamespace(chunks=chunks, raise_error=raise_error)
@@ -144,3 +164,28 @@ def test_cache_key_is_unchanged_when_speed_is_unset():
144
164
  old_payload = f"{settings.voice_id}|{settings.model_id}|{settings.output_format}|hi"
145
165
 
146
166
  assert _cache_key("hi", settings) == hashlib.sha256(old_payload.encode("utf-8")).hexdigest()
167
+
168
+
169
+ def test_get_usage_returns_tier_used_limit_and_reset():
170
+ client = SimpleNamespace(
171
+ user=FakeUserNamespace(tier="creator", used=4200, limit=100000, resets_at=1735689600)
172
+ )
173
+
174
+ result = get_usage(client)
175
+
176
+ assert result == {"tier": "creator", "used": 4200, "limit": 100000, "resets_at": 1735689600}
177
+
178
+
179
+ def test_get_usage_passes_through_a_missing_reset_time():
180
+ client = SimpleNamespace(user=FakeUserNamespace(resets_at=None))
181
+
182
+ result = get_usage(client)
183
+
184
+ assert result["resets_at"] is None
185
+
186
+
187
+ def test_get_usage_wraps_sdk_errors():
188
+ client = SimpleNamespace(user=FakeUserNamespace(raise_error=RuntimeError("unauthorized")))
189
+
190
+ with pytest.raises(TTSRequestError, match="unauthorized"):
191
+ get_usage(client)
@@ -7,4 +7,4 @@ formatting into something that actually sounds good spoken aloud
7
7
  which is close to useless).
8
8
  """
9
9
 
10
- __version__ = "0.3.0"
10
+ __version__ = "0.4.0"
@@ -4,6 +4,7 @@
4
4
  vocalize speak-file report.md --play
5
5
  cat notes.md | vocalize speak-file - --play
6
6
  vocalize voices
7
+ vocalize usage
7
8
  vocalize config
8
9
  vocalize auth login
9
10
  """
@@ -11,6 +12,7 @@
11
12
  from __future__ import annotations
12
13
 
13
14
  import sys
15
+ from datetime import datetime, timezone
14
16
  from pathlib import Path
15
17
 
16
18
  import click
@@ -22,7 +24,7 @@ from .auth import delete_key, key_source, login, masked, probe_keychain, prompt_
22
24
  from .config import DEFAULT_MODEL, DEFAULT_VOICE, resolve_api_key, resolve_settings
23
25
  from .exceptions import TTSRequestError, VocalizeError
24
26
  from .preprocess import flatten_markdown, truncate_for_budget
25
- from .tts import DEFAULT_CACHE_DIR, build_client, list_voices, synthesize
27
+ from .tts import DEFAULT_CACHE_DIR, build_client, get_usage, list_voices, synthesize
26
28
  from .wizard import run_wizard
27
29
 
28
30
 
@@ -107,6 +109,47 @@ def voices(api_key) -> None:
107
109
  click.echo(f"{v['id']}\t{v['name']}")
108
110
 
109
111
 
112
+ def _human_readable_size(num_bytes: int) -> str:
113
+ size = float(num_bytes)
114
+ for unit in ("B", "KB", "MB", "GB"):
115
+ if size < 1024 or unit == "GB":
116
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
117
+ size /= 1024
118
+ return f"{size:.1f} GB"
119
+
120
+
121
+ @main.command()
122
+ @click.option("--api-key", default=None)
123
+ def usage(api_key) -> None:
124
+ """Show ElevenLabs quota usage and local cache stats."""
125
+ key = resolve_api_key(api_key)
126
+ client = build_client(key)
127
+ stats = get_usage(client)
128
+
129
+ used, limit = stats["used"], stats["limit"]
130
+ percent = (used / limit * 100) if limit else 0.0
131
+ remaining = max(limit - used, 0)
132
+
133
+ click.echo(f"Tier: {stats['tier']}")
134
+ click.echo(f"Used: {used:,} / {limit:,} characters ({percent:.1f}%)")
135
+ click.echo(f"Remaining: {remaining:,} characters")
136
+ if stats["resets_at"] is not None:
137
+ # UTC-aware, then converted to the system's local zone explicitly —
138
+ # a bare fromtimestamp() is implicitly local, which ruff (DTZ006)
139
+ # flags as ambiguous.
140
+ local_reset = datetime.fromtimestamp(stats["resets_at"], tz=timezone.utc).astimezone()
141
+ click.echo(f"Resets: {local_reset.strftime('%Y-%m-%d')}")
142
+
143
+ click.echo("")
144
+ # Local cache stats are pure filesystem lookups — no API call, no quota.
145
+ cache_files = list(DEFAULT_CACHE_DIR.glob("*.mp3")) if DEFAULT_CACHE_DIR.is_dir() else []
146
+ if not cache_files:
147
+ click.echo("Local cache: cache empty")
148
+ else:
149
+ total_bytes = sum(f.stat().st_size for f in cache_files)
150
+ click.echo(f"Local cache: {len(cache_files)} files, {_human_readable_size(total_bytes)}")
151
+
152
+
110
153
  @main.command("config")
111
154
  def config_cmd() -> None:
112
155
  """Interactive setup: pick voice, model, and speed."""
@@ -110,6 +110,25 @@ def list_voices(client) -> list[dict]:
110
110
  ]
111
111
 
112
112
 
113
+ def get_usage(client) -> dict:
114
+ """Return ElevenLabs subscription usage: tier, used, limit, resets_at.
115
+
116
+ `resets_at` is the unix timestamp of the next character-count reset,
117
+ or None on tiers that don't report one. This call costs no quota.
118
+ """
119
+ try:
120
+ subscription = client.user.subscription.get()
121
+ except Exception as exc:
122
+ raise TTSRequestError(f"Could not fetch usage: {exc}") from exc
123
+
124
+ return {
125
+ "tier": subscription.tier,
126
+ "used": subscription.character_count,
127
+ "limit": subscription.character_limit,
128
+ "resets_at": subscription.next_character_count_reset_unix,
129
+ }
130
+
131
+
113
132
  def build_client(api_key: str):
114
133
  """Construct the real ElevenLabs SDK client. Imported lazily so the
115
134
  rest of the package (and its tests) don't require the `elevenlabs`
File without changes
File without changes
File without changes