anyscribe 0.16.0__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.
Files changed (98) hide show
  1. anyscribe/__init__.py +3 -0
  2. anyscribe/__main__.py +12 -0
  3. anyscribe/assets/tray-icon.png +0 -0
  4. anyscribe/cli/__init__.py +0 -0
  5. anyscribe/cli/batch.py +296 -0
  6. anyscribe/cli/config_cmd.py +359 -0
  7. anyscribe/cli/download.py +167 -0
  8. anyscribe/cli/local_cmd.py +303 -0
  9. anyscribe/cli/logs_cmd.py +112 -0
  10. anyscribe/cli/main.py +343 -0
  11. anyscribe/cli/migrate_cmd.py +330 -0
  12. anyscribe/cli/models_cmd.py +350 -0
  13. anyscribe/cli/onboard.py +896 -0
  14. anyscribe/cli/rm.py +59 -0
  15. anyscribe/cli/service_cmd.py +79 -0
  16. anyscribe/cli/skill_cmd.py +66 -0
  17. anyscribe/cli/transcribe.py +261 -0
  18. anyscribe/cli/tray_cmd.py +188 -0
  19. anyscribe/config/__init__.py +0 -0
  20. anyscribe/config/paths.py +61 -0
  21. anyscribe/config/settings.py +188 -0
  22. anyscribe/core/__init__.py +0 -0
  23. anyscribe/core/audio.py +144 -0
  24. anyscribe/core/checkpoint.py +109 -0
  25. anyscribe/core/config_set.py +190 -0
  26. anyscribe/core/dedup.py +49 -0
  27. anyscribe/core/deps.py +401 -0
  28. anyscribe/core/errors.py +197 -0
  29. anyscribe/core/fileutil.py +83 -0
  30. anyscribe/core/local_setup.py +357 -0
  31. anyscribe/core/migrate.py +222 -0
  32. anyscribe/core/onboard_headless.py +283 -0
  33. anyscribe/core/orchestrator.py +207 -0
  34. anyscribe/core/preflight.py +85 -0
  35. anyscribe/core/quality.py +37 -0
  36. anyscribe/core/resolve.py +114 -0
  37. anyscribe/core/service.py +74 -0
  38. anyscribe/core/tray.py +61 -0
  39. anyscribe/core/updater.py +276 -0
  40. anyscribe/downloaders/__init__.py +0 -0
  41. anyscribe/downloaders/base.py +32 -0
  42. anyscribe/downloaders/instagram.py +164 -0
  43. anyscribe/downloaders/local_file.py +74 -0
  44. anyscribe/downloaders/registry.py +37 -0
  45. anyscribe/downloaders/youtube.py +83 -0
  46. anyscribe/mcp/__init__.py +1 -0
  47. anyscribe/mcp/server.py +613 -0
  48. anyscribe/providers/__init__.py +114 -0
  49. anyscribe/providers/base.py +131 -0
  50. anyscribe/providers/deepgram.py +197 -0
  51. anyscribe/providers/elevenlabs.py +113 -0
  52. anyscribe/providers/groq.py +38 -0
  53. anyscribe/providers/languages.py +364 -0
  54. anyscribe/providers/local.py +126 -0
  55. anyscribe/providers/local_models.py +341 -0
  56. anyscribe/providers/openai.py +169 -0
  57. anyscribe/providers/openrouter.py +143 -0
  58. anyscribe/providers/sargam.py +172 -0
  59. anyscribe/skill/SKILL.md +366 -0
  60. anyscribe/skill/__init__.py +0 -0
  61. anyscribe/skill/references/commands.md +682 -0
  62. anyscribe/skill/references/config.md +186 -0
  63. anyscribe/skill/references/providers.md +283 -0
  64. anyscribe/skill/references/troubleshooting.md +301 -0
  65. anyscribe/vault/__init__.py +0 -0
  66. anyscribe/vault/index.py +227 -0
  67. anyscribe/vault/scaffold.py +65 -0
  68. anyscribe/vault/writer.py +237 -0
  69. anyscribe/web/__init__.py +1 -0
  70. anyscribe/web/app.py +80 -0
  71. anyscribe/web/jobs.py +222 -0
  72. anyscribe/web/models.py +72 -0
  73. anyscribe/web/progress.py +28 -0
  74. anyscribe/web/routes/__init__.py +1 -0
  75. anyscribe/web/routes/config.py +338 -0
  76. anyscribe/web/routes/health.py +22 -0
  77. anyscribe/web/routes/history.py +159 -0
  78. anyscribe/web/routes/local.py +199 -0
  79. anyscribe/web/routes/models.py +221 -0
  80. anyscribe/web/routes/onboarding.py +115 -0
  81. anyscribe/web/routes/system.py +16 -0
  82. anyscribe/web/routes/transcribe.py +159 -0
  83. anyscribe/web/static/assets/index-6nMwlEoM.js +100 -0
  84. anyscribe/web/static/assets/index-Dv8MANvD.css +2 -0
  85. anyscribe/web/static/doodles-desktop-2.png +0 -0
  86. anyscribe/web/static/doodles-desktop.png +0 -0
  87. anyscribe/web/static/doodles-mobile-2.png +0 -0
  88. anyscribe/web/static/doodles-mobile.png +0 -0
  89. anyscribe/web/static/favicon.svg +1 -0
  90. anyscribe/web/static/index.html +17 -0
  91. anyscribe/web/static/kx-logo-white-128.png +0 -0
  92. anyscribe/web/static/kx-logo-white-32.png +0 -0
  93. anyscribe/web/static/kx-logo-white-64.png +0 -0
  94. anyscribe-0.16.0.dist-info/METADATA +372 -0
  95. anyscribe-0.16.0.dist-info/RECORD +98 -0
  96. anyscribe-0.16.0.dist-info/WHEEL +4 -0
  97. anyscribe-0.16.0.dist-info/entry_points.txt +6 -0
  98. anyscribe-0.16.0.dist-info/licenses/LICENSE +21 -0
anyscribe/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """anyscribe — download, transcribe, and convert video/audio to structured markdown."""
2
+
3
+ __version__ = "0.16.0"
anyscribe/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Allow running as `python -m anyscribe`.
2
+
3
+ This is the recommended way to run scribe on Windows when the Python
4
+ Scripts directory is not on PATH:
5
+
6
+ python -m anyscribe onboard
7
+ python -m anyscribe "https://..."
8
+ """
9
+
10
+ from anyscribe.cli.main import app
11
+
12
+ app()
Binary file
File without changes
anyscribe/cli/batch.py ADDED
@@ -0,0 +1,296 @@
1
+ """Batch processing command — transcribe multiple URLs from a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn
12
+ from rich.table import Table
13
+
14
+ from anyscribe.config.settings import load_config, load_env
15
+
16
+ console = Console()
17
+ err_console = Console(stderr=True)
18
+
19
+
20
+ def batch(
21
+ file: Path = typer.Argument(..., help="File containing URLs or file paths (one per line)."),
22
+ provider: str | None = typer.Option(None, "--provider", "-p", help="Override provider."),
23
+ model: str | None = typer.Option(None, "--model", "-m", help="Override the provider's model."),
24
+ quality: str | None = typer.Option(
25
+ None, "--quality", help="Quality preset: accuracy | balanced | cost | free."
26
+ ),
27
+ language: str | None = typer.Option(None, "--language", "-l", help="Override language."),
28
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output results as JSON."),
29
+ keep_media: bool = typer.Option(False, "--keep-media", help="Keep audio files."),
30
+ diarize: bool = typer.Option(False, "--diarize", "-d", help="Enable speaker diarization."),
31
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress progress."),
32
+ stop_on_error: bool = typer.Option(False, "--stop-on-error", help="Stop at first failure."),
33
+ resume: bool = typer.Option(False, "--resume", help="Resume from last checkpoint."),
34
+ force: bool = typer.Option(
35
+ False, "--force", "-f", help="Re-transcribe even if a source was already transcribed."
36
+ ),
37
+ timeout: float | None = typer.Option(
38
+ None, "--timeout", help="Per-URL timeout in seconds (default: no timeout)."
39
+ ),
40
+ ) -> None:
41
+ """[bold magenta]Batch transcribe[/bold magenta] URLs or local files from a list.
42
+
43
+ Reads a file with one URL or file path per line (blank lines and #comments
44
+ are skipped). Processes each entry sequentially and reports results.
45
+ """
46
+ if not file.exists():
47
+ err_console.print(f"[red]File not found:[/red] {file}")
48
+ raise typer.Exit(code=1)
49
+
50
+ # Parse URLs from file
51
+ urls: list[str] = []
52
+ for line in file.read_text().splitlines():
53
+ line = line.strip()
54
+ if line and not line.startswith("#"):
55
+ urls.append(line)
56
+
57
+ if not urls:
58
+ err_console.print("[yellow]No URLs or file paths found in file.[/yellow]")
59
+ raise typer.Exit()
60
+
61
+ # Deduplicate while preserving order
62
+ original_count = len(urls)
63
+ urls = list(dict.fromkeys(urls))
64
+ if len(urls) < original_count and not quiet:
65
+ err_console.print(f" [dim]Removed {original_count - len(urls)} duplicate(s)[/dim]")
66
+
67
+ # Resume from checkpoint
68
+ state_file = file.with_suffix(file.suffix + ".state.json")
69
+ if resume and state_file.exists():
70
+ import json as _json
71
+
72
+ state = _json.loads(state_file.read_text())
73
+ completed_urls = set(state.get("completed", []))
74
+ skipped = len([u for u in urls if u in completed_urls])
75
+ urls = [u for u in urls if u not in completed_urls]
76
+ if skipped and not quiet:
77
+ err_console.print(f" [dim]Resuming — skipping {skipped} already completed[/dim]")
78
+ elif not resume and state_file.exists():
79
+ state_file.unlink()
80
+
81
+ if not urls:
82
+ err_console.print("[green]All URLs already processed. Nothing to do.[/green]")
83
+ raise typer.Exit()
84
+
85
+ load_env()
86
+ settings = load_config()
87
+ if quality:
88
+ settings.quality = quality
89
+ if language:
90
+ settings.language = language
91
+ if keep_media:
92
+ settings.keep_media = True
93
+ if diarize:
94
+ settings.diarize = True
95
+ if settings.output_format == "clean":
96
+ settings.output_format = "diarized"
97
+
98
+ from anyscribe.cli.transcribe import print_plan
99
+ from anyscribe.core.resolve import resolve_run
100
+
101
+ try:
102
+ plan = resolve_run(settings, cli_provider=provider, cli_model=model, diarize=diarize)
103
+ except ValueError as e:
104
+ err_console.print(f"[red]Error:[/red] {e}")
105
+ raise typer.Exit(code=1)
106
+ settings.provider = plan.provider
107
+ print_plan(plan, quiet=quiet)
108
+
109
+ results: list[dict] = []
110
+ succeeded = 0
111
+ failed = 0
112
+
113
+ if quiet or output_json:
114
+ # No progress display
115
+ for url in urls:
116
+ succeeded, failed = _process_url(
117
+ url,
118
+ settings,
119
+ results,
120
+ succeeded,
121
+ failed,
122
+ quiet=True,
123
+ force=force,
124
+ timeout=timeout,
125
+ model=plan.model,
126
+ )
127
+ _save_batch_state(state_file, url, results[-1].get("success", False))
128
+ if failed and stop_on_error:
129
+ break
130
+ else:
131
+ # Rich progress bar
132
+ with Progress(
133
+ SpinnerColumn(),
134
+ TextColumn("[bold]{task.description}"),
135
+ BarColumn(),
136
+ MofNCompleteColumn(),
137
+ console=err_console,
138
+ ) as progress:
139
+ task = progress.add_task("Transcribing", total=len(urls))
140
+
141
+ for url in urls:
142
+ progress.update(task, description=f"[bold]{_shorten_url(url)}")
143
+ succeeded, failed = _process_url(
144
+ url,
145
+ settings,
146
+ results,
147
+ succeeded,
148
+ failed,
149
+ quiet=True,
150
+ force=force,
151
+ timeout=timeout,
152
+ model=plan.model,
153
+ )
154
+ _save_batch_state(state_file, url, results[-1].get("success", False))
155
+ progress.advance(task)
156
+ if failed and stop_on_error:
157
+ err_console.print("[red]Stopping on error (--stop-on-error).[/red]")
158
+ break
159
+
160
+ # Clean up state file on full completion (no failures or no stop-on-error)
161
+ if failed == 0 and state_file.exists():
162
+ state_file.unlink(missing_ok=True)
163
+
164
+ # Summary
165
+ if output_json:
166
+ json.dump(
167
+ {
168
+ "success": failed == 0,
169
+ "data": {
170
+ "total": len(urls),
171
+ "succeeded": succeeded,
172
+ "failed": failed,
173
+ "provider": plan.provider,
174
+ "model": plan.model,
175
+ "via": plan.via,
176
+ "notes": plan.notes,
177
+ "results": results,
178
+ },
179
+ "error": None,
180
+ },
181
+ sys.stdout,
182
+ indent=2,
183
+ )
184
+ sys.stdout.write("\n")
185
+ else:
186
+ console.print(
187
+ f"\n[bold]Batch complete:[/bold] {succeeded} succeeded, {failed} failed, {len(urls)} total"
188
+ )
189
+
190
+ if results:
191
+ table = Table(title="Results")
192
+ table.add_column("#", style="dim")
193
+ table.add_column("Status")
194
+ table.add_column("Title / Error")
195
+
196
+ for i, r in enumerate(results, 1):
197
+ if r["success"]:
198
+ status = "[yellow]CACHED[/yellow]" if r.get("cached") else "[green]OK[/green]"
199
+ table.add_row(str(i), status, r["title"])
200
+ else:
201
+ table.add_row(str(i), "[red]FAIL[/red]", r["error"][:80])
202
+
203
+ console.print(table)
204
+
205
+ if failed > 0:
206
+ raise typer.Exit(code=1)
207
+
208
+
209
+ def _save_batch_state(state_file: Path, url: str, success: bool) -> None:
210
+ """Append completed URL to state file for resume support."""
211
+ if not success:
212
+ return
213
+ from anyscribe.core.fileutil import atomic_write
214
+
215
+ state: dict = {"completed": []}
216
+ if state_file.exists():
217
+ state = json.loads(state_file.read_text())
218
+ state.setdefault("completed", []).append(url)
219
+ atomic_write(state_file, json.dumps(state, indent=2))
220
+
221
+
222
+ def _shorten_url(url: str, max_len: int = 50) -> str:
223
+ """Shorten a URL for display in progress bar."""
224
+ url = url.replace("https://", "").replace("http://", "").replace("www.", "")
225
+ if len(url) > max_len:
226
+ return url[: max_len - 3] + "..."
227
+ return url
228
+
229
+
230
+ def _process_url(
231
+ url: str,
232
+ settings,
233
+ results: list[dict],
234
+ succeeded: int,
235
+ failed: int,
236
+ quiet: bool,
237
+ force: bool = False,
238
+ timeout: float | None = None,
239
+ model: str | None = None,
240
+ ) -> tuple[int, int]:
241
+ """Process a single URL, append to results. Returns (succeeded, failed)."""
242
+ from anyscribe.core.orchestrator import process
243
+
244
+ try:
245
+ if timeout is None:
246
+ result = process(url, settings, quiet=quiet, force=force, model=model)
247
+ else:
248
+ # ponytail: Python can't kill a running thread, so a timed-out worker
249
+ # keeps transcribing in the background even though we've moved on and
250
+ # reported it failed. Fine for a CLI batch run; if this ever needs to
251
+ # actually cancel work (e.g. to free API concurrency), switch to
252
+ # subprocess isolation (multiprocessing/concurrent.futures.ProcessPoolExecutor)
253
+ # so the timeout can terminate the process, not just abandon the thread.
254
+ from concurrent.futures import ThreadPoolExecutor
255
+ from concurrent.futures import TimeoutError as FutureTimeoutError
256
+
257
+ with ThreadPoolExecutor(max_workers=1) as executor:
258
+ future = executor.submit(
259
+ process, url, settings, quiet=quiet, force=force, model=model
260
+ )
261
+ try:
262
+ result = future.result(timeout=timeout)
263
+ except FutureTimeoutError:
264
+ raise TimeoutError(f"timed out after {timeout:g}s") from None
265
+ succeeded += 1
266
+ results.append(
267
+ {
268
+ "success": True,
269
+ "url": url,
270
+ "cached": result.cached,
271
+ "file": str(result.file_path),
272
+ "title": result.title,
273
+ "platform": result.platform,
274
+ "duration": result.duration,
275
+ "language": result.language,
276
+ "word_count": result.word_count,
277
+ }
278
+ )
279
+ except Exception as e:
280
+ from anyscribe.core.errors import ScribeAPIError
281
+
282
+ if isinstance(e, TimeoutError):
283
+ error_msg = str(e)
284
+ elif isinstance(e, ScribeAPIError):
285
+ error_msg = e.user_message
286
+ else:
287
+ error_msg = str(e)
288
+ failed += 1
289
+ results.append(
290
+ {
291
+ "success": False,
292
+ "url": url,
293
+ "error": error_msg,
294
+ }
295
+ )
296
+ return succeeded, failed
@@ -0,0 +1,359 @@
1
+ """Config and providers CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import typer
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from anyscribe.config.paths import CONFIG_FILE
16
+ from anyscribe.config.settings import Settings, load_config, load_env
17
+ from anyscribe.core.config_set import API_KEY_MAP, set_value
18
+ from anyscribe.core.quality import QUALITY_TIERS, has_key
19
+ from anyscribe.providers import (
20
+ PROVIDER_KEY_ENV,
21
+ PROVIDER_MODELS,
22
+ get_models,
23
+ get_provider,
24
+ list_providers,
25
+ )
26
+
27
+ console = Console()
28
+ err_console = Console(stderr=True)
29
+
30
+ # ── Config subcommands ────────────────────────────────────────
31
+
32
+ config_app = typer.Typer(
33
+ name="config",
34
+ rich_markup_mode="rich",
35
+ invoke_without_command=True,
36
+ )
37
+
38
+
39
+ def _provider_rows(settings: Settings) -> list[dict]:
40
+ """One row per provider: what it would run, whether it can, how it's reached."""
41
+ tier_of = {prov: word for word, prov in QUALITY_TIERS.items()}
42
+ rows = []
43
+ for name in list_providers():
44
+ models = get_models(name, settings.extra_models)
45
+ pinned = settings.provider_models.get(name)
46
+ rows.append(
47
+ {
48
+ "name": name,
49
+ "default_model": pinned or (models[0] if models else None),
50
+ "models": models,
51
+ "has_key": has_key(name),
52
+ "tier": tier_of.get(name),
53
+ "pinned": bool(pinned),
54
+ "custom_models": settings.extra_models.get(name, []),
55
+ }
56
+ )
57
+ return rows
58
+
59
+
60
+ @config_app.callback(invoke_without_command=True)
61
+ def config_main(
62
+ ctx: typer.Context,
63
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
64
+ ) -> None:
65
+ """[bold]View and change[/bold] scribe settings. No subcommand shows the defaults dashboard."""
66
+ if ctx.invoked_subcommand is not None:
67
+ return
68
+
69
+ # resolve_run pulls httpx in through the provider modules — keep it off the
70
+ # import path of every other `scribe` command.
71
+ from anyscribe.core.resolve import resolve_run
72
+
73
+ load_env()
74
+ settings = load_config()
75
+ try:
76
+ plan = resolve_run(settings)
77
+ except ValueError as e:
78
+ # A hand-edited/downgraded config can hold an unknown provider — the
79
+ # dashboard is where users diagnose that, so it must not traceback.
80
+ if output_json:
81
+ json.dump({"error": str(e), **settings.to_dict()}, sys.stdout, indent=2)
82
+ sys.stdout.write("\n")
83
+ else:
84
+ err_console.print(f"[red]Error:[/red] {e}")
85
+ err_console.print("[dim]Fix with: scribe config set provider <name>[/dim]")
86
+ raise typer.Exit(code=1)
87
+ rows = _provider_rows(settings)
88
+
89
+ if output_json:
90
+ data = settings.to_dict()
91
+ data["resolved"] = {
92
+ "provider": plan.provider,
93
+ "model": plan.model,
94
+ "via": plan.via,
95
+ "notes": plan.notes,
96
+ }
97
+ data["providers"] = rows
98
+ json.dump(data, sys.stdout, indent=2)
99
+ sys.stdout.write("\n")
100
+ return
101
+
102
+ model = plan.model or (settings.local_model if plan.provider == "local" else "default")
103
+ console.print(f"Next run: [bold]{plan.provider}[/bold] · {model} [dim]({plan.via})[/dim]")
104
+ for note in plan.notes:
105
+ console.print(f" [dim]{note}[/dim]")
106
+
107
+ table = Table(box=None, pad_edge=False)
108
+ table.add_column("Provider", style="bold")
109
+ table.add_column("Default model")
110
+ table.add_column("Alternatives", style="dim")
111
+ table.add_column("Key")
112
+ table.add_column("Notes", style="dim")
113
+
114
+ for row in rows:
115
+ name = row["name"]
116
+ others = [m for m in row["models"] if m != row["default_model"]]
117
+ alts = ", ".join(others) if len(others) <= 2 else f"{len(others)} more"
118
+ if PROVIDER_KEY_ENV[name] is None:
119
+ key = "[dim]—[/dim]"
120
+ else:
121
+ key = "[green]✓[/green]" if row["has_key"] else "[yellow]missing[/yellow]"
122
+ notes = [row["tier"]] if row["tier"] else []
123
+ if row["pinned"]:
124
+ notes.append("pinned")
125
+ if row["custom_models"]:
126
+ notes.append(f"{len(row['custom_models'])} custom")
127
+ if name == "local":
128
+ notes.append(f"local_model: {settings.local_model}")
129
+ table.add_row(
130
+ f"→ {name}" if name == plan.provider else f" {name}",
131
+ row["default_model"] or "—",
132
+ alts,
133
+ key,
134
+ ", ".join(notes),
135
+ )
136
+
137
+ console.print()
138
+ console.print(table)
139
+ console.print()
140
+ missing = [r["name"] for r in rows if not r["has_key"]]
141
+ if missing:
142
+ console.print(
143
+ f"[dim]Missing keys: {', '.join(missing)} "
144
+ f"(scribe config set <provider>_api_key <key>)[/dim]"
145
+ )
146
+ console.print(
147
+ "[dim]Change provider: scribe config set provider <name> "
148
+ "(also sets quality = custom, so it sticks)[/dim]"
149
+ )
150
+ console.print(
151
+ "[dim]Pin a model: scribe config set provider_models.<provider> <model>[/dim]"
152
+ )
153
+ console.print(
154
+ "[dim]Or pick a tier: scribe config set quality accuracy|balanced|cost|free|custom[/dim]"
155
+ )
156
+
157
+
158
+ @config_app.command("show")
159
+ def config_show(
160
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
161
+ ) -> None:
162
+ """[bold]Show[/bold] current configuration."""
163
+ settings = load_config()
164
+ data = settings.to_dict()
165
+
166
+ if output_json:
167
+ from anyscribe.config.paths import get_workspace_dir
168
+
169
+ data["_resolved_workspace"] = str(get_workspace_dir())
170
+ json.dump(data, sys.stdout, indent=2)
171
+ sys.stdout.write("\n")
172
+ else:
173
+ from anyscribe.config.paths import get_workspace_dir
174
+
175
+ console.print(f"[dim]Config file: {CONFIG_FILE}[/dim]\n")
176
+ console.print(yaml.dump(data, default_flow_style=False, sort_keys=False).strip())
177
+ console.print(f"\n[dim]Workspace: {get_workspace_dir()}[/dim]")
178
+
179
+
180
+ @config_app.command("set")
181
+ def config_set(
182
+ key: str = typer.Argument(
183
+ ..., help="Setting key (e.g., 'provider', 'language', 'instagram.browser')."
184
+ ),
185
+ value: str = typer.Argument(..., help="New value."),
186
+ ) -> None:
187
+ """[bold]Change[/bold] a configuration setting.
188
+
189
+ Use dot-notation for nested keys: `scribe config set instagram.browser firefox`
190
+ """
191
+ outcome = set_value(key, value)
192
+ if not outcome.ok:
193
+ err_console.print(f"[red]{outcome.error}[/red]")
194
+ if outcome.choices:
195
+ err_console.print(f"Available: {', '.join(outcome.choices)}")
196
+ raise typer.Exit(code=1)
197
+ console.print(f"[green]{outcome.message}[/green]")
198
+
199
+
200
+ @config_app.command("path")
201
+ def config_path() -> None:
202
+ """[bold]Print[/bold] the config file location."""
203
+ console.print(str(CONFIG_FILE))
204
+
205
+
206
+ def _flat_items(d: dict, prefix: str = "") -> list[tuple[str, str, str]]:
207
+ """Flatten a dict into (key, type_name, value) tuples."""
208
+ items = []
209
+ for k, v in d.items():
210
+ full = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
211
+ if isinstance(v, dict):
212
+ items.extend(_flat_items(v, full))
213
+ else:
214
+ items.append((full, type(v).__name__, str(v)))
215
+ return items
216
+
217
+
218
+ @config_app.command("list-keys")
219
+ def config_list_keys(
220
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
221
+ ) -> None:
222
+ """[bold]List[/bold] all settable configuration keys with types and current values."""
223
+ load_env()
224
+ settings = load_config()
225
+ data = settings.to_dict()
226
+ # Rendered explicitly below so every pinnable provider has a row even when
227
+ # nothing is pinned — an empty dict flattens to nothing.
228
+ data.pop("provider_models")
229
+ data.pop("extra_models")
230
+ items = _flat_items(data)
231
+ for name in list_providers():
232
+ if PROVIDER_MODELS.get(name):
233
+ items.append(
234
+ (
235
+ f"provider_models.{name}",
236
+ "str",
237
+ settings.provider_models.get(name) or "(default)",
238
+ )
239
+ )
240
+ items.append(
241
+ (
242
+ "extra_models.openrouter",
243
+ "list",
244
+ ", ".join(settings.extra_models.get("openrouter") or []) or "(none)",
245
+ )
246
+ )
247
+
248
+ # Add API key entries
249
+ api_keys = []
250
+ for key_name, env_var in API_KEY_MAP.items():
251
+ val = os.environ.get(env_var, "")
252
+ masked = f"{val[:4]}...{val[-4:]}" if len(val) > 8 else ("(set)" if val else "(not set)")
253
+ api_keys.append((key_name, "secret", masked))
254
+
255
+ all_items = items + api_keys
256
+
257
+ if output_json:
258
+ result = [{"key": k, "type": t, "value": v} for k, t, v in all_items]
259
+ json.dump(result, sys.stdout, indent=2)
260
+ sys.stdout.write("\n")
261
+ else:
262
+ table = Table(title="All Settable Keys")
263
+ table.add_column("Key", style="bold cyan")
264
+ table.add_column("Type", style="dim")
265
+ table.add_column("Current Value")
266
+ for key, type_name, value in all_items:
267
+ table.add_row(key, type_name, value)
268
+ console.print(table)
269
+
270
+
271
+ # ── Providers subcommands ─────────────────────────────────────
272
+
273
+ providers_app = typer.Typer(
274
+ name="providers",
275
+ help="Manage transcription providers.",
276
+ rich_markup_mode="rich",
277
+ no_args_is_help=True,
278
+ )
279
+
280
+
281
+ @providers_app.command("list")
282
+ def providers_list(
283
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON."),
284
+ ) -> None:
285
+ """[bold]List[/bold] available transcription providers."""
286
+ settings = load_config()
287
+ active = settings.provider
288
+ rows = _provider_rows(settings)
289
+
290
+ if output_json:
291
+ result = [
292
+ {
293
+ "name": r["name"],
294
+ "active": r["name"] == active,
295
+ "model": r["default_model"] or "",
296
+ "models": r["models"],
297
+ "custom_models": r["custom_models"],
298
+ }
299
+ for r in rows
300
+ ]
301
+ json.dump(result, sys.stdout, indent=2)
302
+ sys.stdout.write("\n")
303
+ else:
304
+ table = Table(title="Transcription Providers")
305
+ table.add_column("Provider", style="bold")
306
+ table.add_column("Model")
307
+ table.add_column("Also available", style="dim")
308
+ table.add_column("Active")
309
+
310
+ for r in rows:
311
+ current = r["default_model"] or ""
312
+ others = ", ".join(
313
+ f"{m} (custom)" if m in r["custom_models"] else m
314
+ for m in r["models"]
315
+ if m != current
316
+ )
317
+ is_active = "[green]Active[/green]" if r["name"] == active else ""
318
+ table.add_row(r["name"], current, others, is_active)
319
+
320
+ console.print(table)
321
+ console.print("\n[dim]Change with: scribe config set provider <name>[/dim]")
322
+ console.print(
323
+ "[dim]Pin a model: scribe config set provider_models.<provider> <model> "
324
+ "(or per-run: scribe <url> -p <provider> -m <model>)[/dim]"
325
+ )
326
+
327
+
328
+ @providers_app.command("test")
329
+ def providers_test(
330
+ name: Optional[str] = typer.Argument(None, help="Provider to test (default: active provider)."),
331
+ ) -> None:
332
+ """[bold]Test[/bold] a provider's API key and connectivity."""
333
+ load_env()
334
+ settings = load_config()
335
+ provider_name = name or settings.provider
336
+
337
+ console.print(f"Testing provider: [bold]{provider_name}[/bold]")
338
+
339
+ try:
340
+ provider = get_provider(provider_name)
341
+ except ValueError as e:
342
+ err_console.print(f"[red]{e}[/red]")
343
+ raise typer.Exit(code=1)
344
+
345
+ # Check if API key is set
346
+ env_var = PROVIDER_KEY_ENV.get(provider_name)
347
+ if env_var:
348
+ if os.environ.get(env_var):
349
+ console.print(f" API key ({env_var}): [green]Set[/green]")
350
+ else:
351
+ console.print(f" API key ({env_var}): [red]Not set[/red]")
352
+ console.print(" Add it to ~/.anyscribe/.env")
353
+ raise typer.Exit(code=1)
354
+
355
+ if provider_name == "local":
356
+ console.print(" [green]Local provider — no API key needed.[/green]")
357
+
358
+ console.print(f" Provider class: {provider.__class__.__name__}")
359
+ console.print(" [green]Provider loaded successfully.[/green]")