getinvoke 0.5.3__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.
getinvoke/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Invoke — Voice-to-prompt for vibe coders."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("getinvoke")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
getinvoke/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m getinvoke`."""
2
+
3
+ from getinvoke.cli import main
4
+
5
+ main()
getinvoke/app.py ADDED
@@ -0,0 +1,93 @@
1
+ """GUI app — thin wrapper that wires InvokeEngine to the GUI frontend."""
2
+
3
+ import logging
4
+
5
+ from getinvoke.engine import InvokeEngine
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class GuiFrontend:
11
+ """Frontend that uses pystray tray icon + tkinter overlay."""
12
+
13
+ def __init__(self) -> None:
14
+ # Lazy imports — only loaded when GUI is actually used
15
+ from getinvoke.overlay import RecordingOverlay
16
+ from getinvoke.tray import TrayApp
17
+
18
+ self._tray = TrayApp(on_quit=self._on_quit_requested, on_mode_change=self._on_mode_change)
19
+ self._overlay = RecordingOverlay()
20
+ self._engine: InvokeEngine | None = None
21
+ self._quit_callback = None
22
+
23
+ def set_engine(self, engine: InvokeEngine) -> None:
24
+ """Wire up the engine reference (needed for quit callback + license manager)."""
25
+ self._engine = engine
26
+ self._tray._on_quit = engine.request_quit
27
+
28
+ def start(self) -> None:
29
+ self._tray.start()
30
+ self._overlay.start()
31
+
32
+ # Pass license info to tray
33
+ if self._engine:
34
+ self._tray.set_license_manager(self._engine.license_manager)
35
+
36
+ def stop(self) -> None:
37
+ self._overlay.stop()
38
+ self._tray.stop()
39
+
40
+ def on_state_change(self, state: str) -> None:
41
+ self._tray.set_state(state)
42
+
43
+ def on_recording_started(self) -> None:
44
+ self._overlay.show_recording()
45
+
46
+ def on_recording_stopped(self, duration: float) -> None:
47
+ pass # Overlay handles its own timer
48
+
49
+ def on_processing(self) -> None:
50
+ self._overlay.show_processing()
51
+
52
+ def on_result(self, raw: str, reformatted: str, duration_ms: int) -> None:
53
+ self._tray.set_tooltip(f"'{reformatted[:50]}...'")
54
+ self._overlay.show_done(reformatted[:50])
55
+
56
+ def on_error(self, message: str) -> None:
57
+ self._overlay.show_error(message)
58
+
59
+ def notify(self, message: str, title: str) -> None:
60
+ self._tray.notify(message, title)
61
+
62
+ def on_first_run(self) -> None:
63
+ from getinvoke.wizard import run_wizard
64
+
65
+ run_wizard()
66
+
67
+ def on_license_needed(self, message: str) -> bool:
68
+ from getinvoke.license_gui import show_activation_dialog
69
+
70
+ if self._engine:
71
+ return show_activation_dialog(self._engine.license_manager, message=message)
72
+ return False
73
+
74
+ def _on_quit_requested(self) -> None:
75
+ if self._engine:
76
+ self._engine.request_quit()
77
+
78
+ def _on_mode_change(self, mode: str) -> None:
79
+ logger.info(f"Mode changed to: {mode}")
80
+ self._tray.set_tooltip(f"Mode: {mode}")
81
+
82
+
83
+ class InvokeApp:
84
+ """The main GUI application — creates engine + GUI frontend."""
85
+
86
+ def __init__(self) -> None:
87
+ self._frontend = GuiFrontend()
88
+ self._engine = InvokeEngine(self._frontend)
89
+ self._frontend.set_engine(self._engine)
90
+
91
+ def run(self) -> None:
92
+ """Start the app — blocks until quit."""
93
+ self._engine.run()
getinvoke/cli.py ADDED
@@ -0,0 +1,536 @@
1
+ """Click CLI: run, devices, install, health, transcribe, modes, history, config, license."""
2
+
3
+ import logging
4
+ import platform
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+
11
+ def _gui_available() -> bool:
12
+ """Check if GUI dependencies (pystray, Pillow, tkinter) are installed."""
13
+ try:
14
+ import tkinter # noqa: F401
15
+
16
+ import PIL # noqa: F401
17
+ import pystray # noqa: F401
18
+
19
+ return True
20
+ except ImportError:
21
+ return False
22
+
23
+
24
+ @click.group(invoke_without_command=True)
25
+ @click.pass_context
26
+ def main(ctx: click.Context) -> None:
27
+ """Dictate — Voice-to-prompt for developers."""
28
+ if ctx.invoked_subcommand is None:
29
+ ctx.invoke(run)
30
+
31
+
32
+ @main.command()
33
+ @click.option("--headless", is_flag=True, help="Force headless mode (no GUI)")
34
+ @click.option("--no-beep", is_flag=True, help="Disable audio feedback beeps")
35
+ def run(headless: bool, no_beep: bool) -> None:
36
+ """Start Invoke (default command)."""
37
+ from getinvoke.config import settings
38
+
39
+ if no_beep:
40
+ settings.BEEP_ENABLED = False
41
+
42
+ is_frozen = getattr(sys, "_MEIPASS", None) is not None
43
+
44
+ if headless:
45
+ from getinvoke.engine import InvokeEngine
46
+ from getinvoke.terminal import TerminalFrontend
47
+
48
+ frontend = TerminalFrontend()
49
+ engine = InvokeEngine(frontend)
50
+ engine.run()
51
+ elif is_frozen or _gui_available():
52
+ from getinvoke.app import InvokeApp
53
+
54
+ app = InvokeApp()
55
+ app.run()
56
+ else:
57
+ from getinvoke.engine import InvokeEngine
58
+ from getinvoke.terminal import TerminalFrontend
59
+
60
+ frontend = TerminalFrontend()
61
+ engine = InvokeEngine(frontend)
62
+ engine.run()
63
+
64
+
65
+ @main.command()
66
+ def devices() -> None:
67
+ """List available audio input devices."""
68
+ from rich.console import Console
69
+ from rich.table import Table
70
+
71
+ from getinvoke.recorder import list_devices
72
+
73
+ console = Console()
74
+ devs = list_devices()
75
+ if not devs:
76
+ console.print("[yellow]No audio input devices found.[/yellow]")
77
+ return
78
+
79
+ table = Table(title=f"Audio Input Devices ({len(devs)} found)")
80
+ table.add_column("Index", style="cyan", justify="right")
81
+ table.add_column("Name", style="white")
82
+ table.add_column("Channels", style="dim", justify="right")
83
+
84
+ for dev in devs:
85
+ table.add_row(str(dev["index"]), dev["name"], f"{dev['channels']}ch")
86
+
87
+ console.print(table)
88
+
89
+
90
+ @main.command()
91
+ def modes() -> None:
92
+ """List available target modes."""
93
+ from rich.console import Console
94
+ from rich.table import Table
95
+
96
+ from getinvoke.config import settings
97
+ from getinvoke.modes import list_modes
98
+
99
+ console = Console()
100
+ table = Table(title="Available Modes")
101
+ table.add_column("Mode", style="cyan")
102
+ table.add_column("Description", style="white")
103
+ table.add_column("Active", style="green", justify="center")
104
+
105
+ for mode in list_modes():
106
+ active = "*" if mode.name == settings.MODE else ""
107
+ table.add_row(mode.name, mode.description, active)
108
+
109
+ console.print(table)
110
+
111
+
112
+ @main.command()
113
+ @click.option("-n", "--limit", default=10, help="Number of entries to show")
114
+ @click.option("-s", "--search", "query", default=None, help="Search history")
115
+ def history(limit: int, query: str | None) -> None:
116
+ """View dictation history."""
117
+ from rich.console import Console
118
+ from rich.table import Table
119
+
120
+ from getinvoke import history as hist
121
+
122
+ console = Console()
123
+ hist.init_db()
124
+
125
+ if query:
126
+ entries = hist.search(query, limit=limit)
127
+ title = f"Search results for '{query}'"
128
+ else:
129
+ entries = hist.get_recent(limit=limit)
130
+ total = hist.count()
131
+ title = f"Recent Dictations ({total} total)"
132
+
133
+ if not entries:
134
+ console.print(f"[dim]{title}[/dim]")
135
+ console.print(" No entries found.")
136
+ hist.close_db()
137
+ return
138
+
139
+ table = Table(title=title)
140
+ table.add_column("Time", style="dim")
141
+ table.add_column("Mode", style="cyan")
142
+ table.add_column("Duration", style="yellow", justify="right")
143
+ table.add_column("Raw", style="white", max_width=40)
144
+ table.add_column("Output", style="bold white", max_width=50)
145
+
146
+ for entry in entries:
147
+ ts = entry["timestamp"][:19]
148
+ mode = entry["mode"]
149
+ raw = entry["raw_text"][:40]
150
+ reformatted = entry["reformatted"][:50]
151
+ duration = f"{entry['duration_ms']}ms" if entry["duration_ms"] else "?"
152
+ table.add_row(ts, mode, duration, raw, reformatted)
153
+
154
+ console.print(table)
155
+ hist.close_db()
156
+
157
+
158
+ @main.command()
159
+ def health() -> None:
160
+ """Check system health: GPU, model, API key, audio devices."""
161
+ from rich.console import Console
162
+ from rich.panel import Panel
163
+ from rich.text import Text
164
+
165
+ from getinvoke.config import settings
166
+ from getinvoke.recorder import list_devices
167
+
168
+ console = Console()
169
+ output = Text()
170
+
171
+ # Python version
172
+ output.append("System\n", style="bold")
173
+ output.append(f" Python: {sys.version.split()[0]}\n")
174
+ output.append(f" Platform: {platform.system()} {platform.release()}\n")
175
+
176
+ # GPU detection
177
+ output.append("\nTranscription\n", style="bold")
178
+ output.append(f" Whisper device: {settings.WHISPER_DEVICE}\n")
179
+ output.append(f" Whisper model: {settings.WHISPER_MODEL}\n")
180
+ output.append(f" Compute type: {settings.whisper_compute_type}\n")
181
+
182
+ try:
183
+ from faster_whisper import WhisperModel # noqa: F401
184
+
185
+ output.append(" faster-whisper: ", style="dim")
186
+ output.append("installed\n", style="green")
187
+ except ImportError:
188
+ output.append(" faster-whisper: ", style="dim")
189
+ output.append("NOT INSTALLED\n", style="red")
190
+
191
+ # CUDA check
192
+ try:
193
+ import ctranslate2
194
+
195
+ cuda_types = ctranslate2.get_supported_compute_types("cuda")
196
+ output.append(f" CUDA compute types: {', '.join(cuda_types)}\n")
197
+
198
+ if settings.WHISPER_DEVICE == "cuda":
199
+ try:
200
+ import numpy as np
201
+
202
+ ctranslate2.StorageView.from_array(np.zeros(10, dtype=np.float32), "cuda")
203
+ output.append(" CUDA runtime: ", style="dim")
204
+ output.append("working\n", style="green")
205
+ except RuntimeError as e:
206
+ output.append(" CUDA runtime: ", style="dim")
207
+ output.append(f"BROKEN — {e}\n", style="red")
208
+ except Exception:
209
+ output.append(" CUDA: ", style="dim")
210
+ output.append("not available (will use CPU)\n", style="yellow")
211
+
212
+ # Reformatter backend
213
+ output.append("\nReformatter\n", style="bold")
214
+ output.append(f" Backend: {settings.REFORMATTER_BACKEND}\n")
215
+ if settings.REFORMATTER_BACKEND == "openrouter":
216
+ has_key = bool(settings.OPENROUTER_API_KEY)
217
+ key_style = "green" if has_key else "red"
218
+ output.append(" API key: ", style="dim")
219
+ output.append(f"{'set' if has_key else 'NOT SET'}\n", style=key_style)
220
+ output.append(f" Model: {settings.DICTATION_MODEL}\n")
221
+ elif settings.REFORMATTER_BACKEND == "claude-cli":
222
+ output.append(f" CLI path: {settings.CLAUDE_CLI_PATH}\n")
223
+ output.append(f" Model: {settings.CLAUDE_CLI_MODEL}\n")
224
+ elif settings.REFORMATTER_BACKEND == "ollama":
225
+ output.append(f" URL: {settings.OLLAMA_URL}\n")
226
+ output.append(f" Model: {settings.OLLAMA_MODEL}\n")
227
+ try:
228
+ import httpx
229
+
230
+ r = httpx.get(f"{settings.OLLAMA_URL}/api/tags", timeout=3.0)
231
+ models = [m["name"] for m in r.json().get("models", [])]
232
+ output.append(f" Available: {', '.join(models[:5]) or 'none'}\n")
233
+ except Exception:
234
+ output.append(" Status: ", style="dim")
235
+ output.append("NOT REACHABLE\n", style="red")
236
+
237
+ # Mode + vocab
238
+ output.append(f"\nMode: {settings.MODE}\n", style="bold")
239
+ if settings.hotwords:
240
+ output.append(f" Custom vocabulary: {', '.join(settings.hotwords)}\n", style="dim")
241
+
242
+ # Audio devices
243
+ output.append("\nAudio\n", style="bold")
244
+ output.append(f" Configured device: {settings.AUDIO_DEVICE or 'default'}\n")
245
+ devs = list_devices()
246
+ output.append(f" Available inputs: {len(devs)}\n")
247
+ for dev in devs[:5]:
248
+ output.append(f" [{dev['index']}] {dev['name']}\n", style="dim")
249
+
250
+ # Hotkey + paste
251
+ output.append(f"\n Hotkey: {settings.HOTKEY}\n")
252
+ output.append(f" Auto-paste: {'on' if settings.AUTO_PASTE else 'off'}\n")
253
+ output.append(f" Audio beeps: {'on' if settings.BEEP_ENABLED else 'off'}\n")
254
+
255
+ # History
256
+ output.append("\nHistory\n", style="bold")
257
+ output.append(f" Enabled: {'yes' if settings.HISTORY_ENABLED else 'no'}\n")
258
+ if settings.HISTORY_ENABLED:
259
+ output.append(f" Retention: {settings.HISTORY_RETENTION_DAYS} days\n", style="dim")
260
+ output.append(f" Database: {settings.db_path}\n", style="dim")
261
+
262
+ # Config + logs
263
+ output.append("\nPaths\n", style="bold")
264
+ log_file = settings.log_dir / "invoke.log"
265
+ output.append(f" Config: {settings.config_file}")
266
+ output.append(f" ({'exists' if settings.config_file.exists() else 'not created'})\n", style="dim")
267
+ output.append(f" Log: {log_file}")
268
+ output.append(f" ({'exists' if log_file.exists() else 'not yet created'})\n", style="dim")
269
+
270
+ # GUI availability
271
+ output.append("\nGUI\n", style="bold")
272
+ if _gui_available():
273
+ output.append(" GUI dependencies: ", style="dim")
274
+ output.append("available\n", style="green")
275
+ else:
276
+ output.append(" GUI dependencies: ", style="dim")
277
+ output.append("not installed (headless only)\n", style="yellow")
278
+ output.append(" Install with: pip install getinvoke[gui]\n", style="dim")
279
+
280
+ console.print(Panel(output, title="Invoke Health Check", border_style="cyan"))
281
+
282
+
283
+ @main.command()
284
+ @click.option("--cli", "use_cli", is_flag=True, help="Force CLI wizard (no GUI)")
285
+ def setup(use_cli: bool) -> None:
286
+ """Run the first-run setup wizard (can be re-run anytime)."""
287
+ from getinvoke.config import settings
288
+
289
+ if use_cli or not _gui_available():
290
+ from getinvoke.setup_cli import run_cli_wizard
291
+
292
+ run_cli_wizard()
293
+ else:
294
+ from getinvoke.wizard import run_wizard
295
+
296
+ run_wizard()
297
+
298
+ # Reload settings from the newly saved config
299
+ settings.__init__()
300
+ click.echo("Setup complete! Restart Invoke to apply changes.")
301
+
302
+
303
+ @main.command()
304
+ @click.argument("key", required=False)
305
+ @click.argument("value", required=False)
306
+ def config(key: str | None, value: str | None) -> None:
307
+ """View or set configuration values.
308
+
309
+ \b
310
+ Examples:
311
+ dictate config # Show all settings
312
+ dictate config mode # Show current mode
313
+ dictate config mode cursor # Set mode to cursor
314
+ """
315
+ from rich.console import Console
316
+ from rich.table import Table
317
+
318
+ from getinvoke.config import settings
319
+
320
+ console = Console()
321
+
322
+ # Settable config keys mapped to Settings attributes
323
+ config_map = {
324
+ "mode": "MODE",
325
+ "hotkey": "HOTKEY",
326
+ "reformatter_backend": "REFORMATTER_BACKEND",
327
+ "whisper_model": "WHISPER_MODEL",
328
+ "whisper_device": "WHISPER_DEVICE",
329
+ "auto_paste": "AUTO_PASTE",
330
+ "audio_device": "AUDIO_DEVICE",
331
+ "openrouter_api_key": "OPENROUTER_API_KEY",
332
+ "dictation_model": "DICTATION_MODEL",
333
+ "claude_cli_path": "CLAUDE_CLI_PATH",
334
+ "claude_cli_model": "CLAUDE_CLI_MODEL",
335
+ "ollama_url": "OLLAMA_URL",
336
+ "ollama_model": "OLLAMA_MODEL",
337
+ "custom_vocab": "CUSTOM_VOCAB",
338
+ "language": "LANGUAGE",
339
+ "log_level": "LOG_LEVEL",
340
+ "history_enabled": "HISTORY_ENABLED",
341
+ "history_retention_days": "HISTORY_RETENTION_DAYS",
342
+ "beep_enabled": "BEEP_ENABLED",
343
+ }
344
+
345
+ if key is None:
346
+ # Show all settings
347
+ table = Table(title="Configuration")
348
+ table.add_column("Key", style="cyan")
349
+ table.add_column("Value", style="white")
350
+
351
+ for config_key, attr in config_map.items():
352
+ val = getattr(settings, attr, "")
353
+ # Mask API keys
354
+ if "key" in config_key.lower() and val:
355
+ display = val[:8] + "..." if len(str(val)) > 8 else "***"
356
+ else:
357
+ display = str(val) if val is not None else ""
358
+ table.add_row(config_key, display)
359
+
360
+ console.print(table)
361
+ console.print(f"\n[dim]Config file: {settings.config_file}[/dim]")
362
+ return
363
+
364
+ if key not in config_map:
365
+ console.print(f"[red]Unknown config key: {key}[/red]")
366
+ console.print(f"[dim]Available keys: {', '.join(sorted(config_map.keys()))}[/dim]")
367
+ return
368
+
369
+ if value is None:
370
+ # Show single value
371
+ val = getattr(settings, config_map[key], "")
372
+ console.print(f" {key} = {val}")
373
+ return
374
+
375
+ # Set value
376
+ attr = config_map[key]
377
+ current = getattr(settings, attr, "")
378
+
379
+ # Type coercion
380
+ if isinstance(current, bool):
381
+ value = value.lower() in ("true", "1", "yes")
382
+ elif isinstance(current, int):
383
+ try:
384
+ value = int(value)
385
+ except ValueError:
386
+ console.print(f"[red]Invalid integer: {value}[/red]")
387
+ return
388
+
389
+ setattr(settings, attr, value)
390
+ settings.save()
391
+ console.print(f" [green]{key}[/green] = {value}")
392
+
393
+
394
+ @main.command()
395
+ @click.argument("action", type=click.Choice(["activate", "status", "deactivate"]))
396
+ @click.argument("key", required=False)
397
+ def license(action: str, key: str | None) -> None:
398
+ """Manage license activation.
399
+
400
+ \b
401
+ Commands:
402
+ dictate license status # Show license status
403
+ dictate license activate <key> # Activate with license key
404
+ dictate license deactivate # Deactivate current license
405
+ """
406
+ from rich.console import Console
407
+ from rich.panel import Panel
408
+ from rich.text import Text
409
+
410
+ from getinvoke.license import LicenseManager, LicenseStatus
411
+
412
+ console = Console()
413
+ lm = LicenseManager()
414
+
415
+ if action == "status":
416
+ status = lm.check()
417
+ output = Text()
418
+ output.append("License Status\n\n", style="bold")
419
+
420
+ status_colors = {
421
+ LicenseStatus.ACTIVE: "green",
422
+ LicenseStatus.TRIAL: "yellow",
423
+ LicenseStatus.GRACE: "yellow",
424
+ LicenseStatus.EXPIRED_TRIAL: "red",
425
+ LicenseStatus.INVALID: "red",
426
+ }
427
+ color = status_colors.get(status, "white")
428
+ output.append(" Status: ", style="dim")
429
+ output.append(f"{status.value}\n", style=color)
430
+
431
+ if status == LicenseStatus.TRIAL:
432
+ output.append(f" Trial days remaining: {lm.trial_days_left}\n")
433
+ if status == LicenseStatus.ACTIVE:
434
+ output.append(f" Updates eligible: {'yes' if lm.updates_eligible else 'no'}\n")
435
+
436
+ console.print(Panel(output, border_style="cyan"))
437
+
438
+ elif action == "activate":
439
+ if not key:
440
+ key = click.prompt("License key")
441
+ success, msg = lm.activate(key)
442
+ if success:
443
+ console.print(f"[green]{msg}[/green]")
444
+ else:
445
+ console.print(f"[red]{msg}[/red]")
446
+
447
+ elif action == "deactivate":
448
+ if not click.confirm("Deactivate your license?"):
449
+ return
450
+ success, msg = lm.deactivate()
451
+ if success:
452
+ console.print(f"[green]{msg}[/green]")
453
+ else:
454
+ console.print(f"[red]{msg}[/red]")
455
+
456
+
457
+ @main.command()
458
+ def install() -> None:
459
+ """Create Windows Startup folder shortcut for auto-launch."""
460
+ if platform.system() != "Windows":
461
+ click.echo("Install command is Windows-only. Use your platform's autostart mechanism.")
462
+ return
463
+
464
+ import os
465
+
466
+ startup_dir = Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
467
+ if not startup_dir.exists():
468
+ click.echo(f"Startup folder not found: {startup_dir}")
469
+ return
470
+
471
+ bat_path = startup_dir / "dictate.bat"
472
+ pythonw = Path(sys.executable).parent / "pythonw.exe"
473
+ if pythonw.exists():
474
+ bat_content = f'@echo off\nstart "" "{pythonw}" -m getinvoke run\n'
475
+ else:
476
+ bat_content = f'@echo off\nstart /min "" "{sys.executable}" -m getinvoke run\n'
477
+ bat_path.write_text(bat_content)
478
+ click.echo(f"Startup shortcut created: {bat_path}")
479
+
480
+
481
+ @main.command()
482
+ @click.option("-n", "--lines", default=50, help="Number of lines to show")
483
+ @click.option("-f", "--follow", is_flag=True, help="Follow log output (like tail -f)")
484
+ def logs(lines: int, follow: bool) -> None:
485
+ """View Invoke log file."""
486
+ from getinvoke.config import settings
487
+
488
+ log_file = settings.log_dir / "invoke.log"
489
+ if not log_file.exists():
490
+ click.echo(f"No log file found at {log_file}")
491
+ click.echo("Run 'dictate run' first to generate logs.")
492
+ return
493
+
494
+ click.echo(f"Log file: {log_file}\n")
495
+
496
+ with open(log_file, encoding="utf-8") as f:
497
+ all_lines = f.readlines()
498
+ for line in all_lines[-lines:]:
499
+ click.echo(line, nl=False)
500
+
501
+ if follow:
502
+ import time
503
+
504
+ click.echo("\n--- Following log (Ctrl+C to stop) ---\n")
505
+ with open(log_file, encoding="utf-8") as f:
506
+ f.seek(0, 2)
507
+ try:
508
+ while True:
509
+ line = f.readline()
510
+ if line:
511
+ click.echo(line, nl=False)
512
+ else:
513
+ time.sleep(0.5)
514
+ except KeyboardInterrupt:
515
+ pass
516
+
517
+
518
+ @main.command()
519
+ @click.argument("wav_file", type=click.Path(exists=True))
520
+ def transcribe(wav_file: str) -> None:
521
+ """One-shot transcription of a WAV file (for debugging)."""
522
+ from getinvoke.transcriber import load_model
523
+ from getinvoke.transcriber import transcribe as do_transcribe
524
+
525
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
526
+
527
+ click.echo("Loading Whisper model...")
528
+ load_model()
529
+
530
+ click.echo(f"Transcribing: {wav_file}")
531
+ text = do_transcribe(wav_file)
532
+ click.echo(f"\nResult:\n{text}")
533
+
534
+
535
+ if __name__ == "__main__":
536
+ main()
getinvoke/clipboard.py ADDED
@@ -0,0 +1,38 @@
1
+ """Cross-platform clipboard access."""
2
+
3
+ import logging
4
+ import time
5
+
6
+ import pyperclip
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def copy(text: str) -> bool:
12
+ """Copy text to system clipboard. Returns True on success."""
13
+ try:
14
+ pyperclip.copy(text)
15
+ logger.info(f"Copied {len(text)} chars to clipboard")
16
+ return True
17
+ except pyperclip.PyperclipException as e:
18
+ logger.error(f"Clipboard error: {e}")
19
+ return False
20
+
21
+
22
+ def paste() -> bool:
23
+ """Simulate Ctrl+V to paste clipboard contents at cursor. Returns True on success."""
24
+ try:
25
+ from pynput.keyboard import Controller, Key
26
+
27
+ kb = Controller()
28
+ # Small delay to ensure clipboard is ready and hotkey is fully released
29
+ time.sleep(0.05)
30
+ kb.press(Key.ctrl)
31
+ kb.press("v")
32
+ kb.release("v")
33
+ kb.release(Key.ctrl)
34
+ logger.info("Auto-pasted via Ctrl+V")
35
+ return True
36
+ except Exception as e:
37
+ logger.error(f"Auto-paste error: {e}")
38
+ return False