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.
- anyscribe/__init__.py +3 -0
- anyscribe/__main__.py +12 -0
- anyscribe/assets/tray-icon.png +0 -0
- anyscribe/cli/__init__.py +0 -0
- anyscribe/cli/batch.py +296 -0
- anyscribe/cli/config_cmd.py +359 -0
- anyscribe/cli/download.py +167 -0
- anyscribe/cli/local_cmd.py +303 -0
- anyscribe/cli/logs_cmd.py +112 -0
- anyscribe/cli/main.py +343 -0
- anyscribe/cli/migrate_cmd.py +330 -0
- anyscribe/cli/models_cmd.py +350 -0
- anyscribe/cli/onboard.py +896 -0
- anyscribe/cli/rm.py +59 -0
- anyscribe/cli/service_cmd.py +79 -0
- anyscribe/cli/skill_cmd.py +66 -0
- anyscribe/cli/transcribe.py +261 -0
- anyscribe/cli/tray_cmd.py +188 -0
- anyscribe/config/__init__.py +0 -0
- anyscribe/config/paths.py +61 -0
- anyscribe/config/settings.py +188 -0
- anyscribe/core/__init__.py +0 -0
- anyscribe/core/audio.py +144 -0
- anyscribe/core/checkpoint.py +109 -0
- anyscribe/core/config_set.py +190 -0
- anyscribe/core/dedup.py +49 -0
- anyscribe/core/deps.py +401 -0
- anyscribe/core/errors.py +197 -0
- anyscribe/core/fileutil.py +83 -0
- anyscribe/core/local_setup.py +357 -0
- anyscribe/core/migrate.py +222 -0
- anyscribe/core/onboard_headless.py +283 -0
- anyscribe/core/orchestrator.py +207 -0
- anyscribe/core/preflight.py +85 -0
- anyscribe/core/quality.py +37 -0
- anyscribe/core/resolve.py +114 -0
- anyscribe/core/service.py +74 -0
- anyscribe/core/tray.py +61 -0
- anyscribe/core/updater.py +276 -0
- anyscribe/downloaders/__init__.py +0 -0
- anyscribe/downloaders/base.py +32 -0
- anyscribe/downloaders/instagram.py +164 -0
- anyscribe/downloaders/local_file.py +74 -0
- anyscribe/downloaders/registry.py +37 -0
- anyscribe/downloaders/youtube.py +83 -0
- anyscribe/mcp/__init__.py +1 -0
- anyscribe/mcp/server.py +613 -0
- anyscribe/providers/__init__.py +114 -0
- anyscribe/providers/base.py +131 -0
- anyscribe/providers/deepgram.py +197 -0
- anyscribe/providers/elevenlabs.py +113 -0
- anyscribe/providers/groq.py +38 -0
- anyscribe/providers/languages.py +364 -0
- anyscribe/providers/local.py +126 -0
- anyscribe/providers/local_models.py +341 -0
- anyscribe/providers/openai.py +169 -0
- anyscribe/providers/openrouter.py +143 -0
- anyscribe/providers/sargam.py +172 -0
- anyscribe/skill/SKILL.md +366 -0
- anyscribe/skill/__init__.py +0 -0
- anyscribe/skill/references/commands.md +682 -0
- anyscribe/skill/references/config.md +186 -0
- anyscribe/skill/references/providers.md +283 -0
- anyscribe/skill/references/troubleshooting.md +301 -0
- anyscribe/vault/__init__.py +0 -0
- anyscribe/vault/index.py +227 -0
- anyscribe/vault/scaffold.py +65 -0
- anyscribe/vault/writer.py +237 -0
- anyscribe/web/__init__.py +1 -0
- anyscribe/web/app.py +80 -0
- anyscribe/web/jobs.py +222 -0
- anyscribe/web/models.py +72 -0
- anyscribe/web/progress.py +28 -0
- anyscribe/web/routes/__init__.py +1 -0
- anyscribe/web/routes/config.py +338 -0
- anyscribe/web/routes/health.py +22 -0
- anyscribe/web/routes/history.py +159 -0
- anyscribe/web/routes/local.py +199 -0
- anyscribe/web/routes/models.py +221 -0
- anyscribe/web/routes/onboarding.py +115 -0
- anyscribe/web/routes/system.py +16 -0
- anyscribe/web/routes/transcribe.py +159 -0
- anyscribe/web/static/assets/index-6nMwlEoM.js +100 -0
- anyscribe/web/static/assets/index-Dv8MANvD.css +2 -0
- anyscribe/web/static/doodles-desktop-2.png +0 -0
- anyscribe/web/static/doodles-desktop.png +0 -0
- anyscribe/web/static/doodles-mobile-2.png +0 -0
- anyscribe/web/static/doodles-mobile.png +0 -0
- anyscribe/web/static/favicon.svg +1 -0
- anyscribe/web/static/index.html +17 -0
- anyscribe/web/static/kx-logo-white-128.png +0 -0
- anyscribe/web/static/kx-logo-white-32.png +0 -0
- anyscribe/web/static/kx-logo-white-64.png +0 -0
- anyscribe-0.16.0.dist-info/METADATA +372 -0
- anyscribe-0.16.0.dist-info/RECORD +98 -0
- anyscribe-0.16.0.dist-info/WHEEL +4 -0
- anyscribe-0.16.0.dist-info/entry_points.txt +6 -0
- anyscribe-0.16.0.dist-info/licenses/LICENSE +21 -0
anyscribe/__init__.py
ADDED
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]")
|