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 +8 -0
- getinvoke/__main__.py +5 -0
- getinvoke/app.py +93 -0
- getinvoke/cli.py +536 -0
- getinvoke/clipboard.py +38 -0
- getinvoke/config.py +187 -0
- getinvoke/context.py +525 -0
- getinvoke/engine.py +393 -0
- getinvoke/feedback.py +62 -0
- getinvoke/history.py +152 -0
- getinvoke/hotkey.py +133 -0
- getinvoke/icon.py +16 -0
- getinvoke/license.py +349 -0
- getinvoke/license_gui.py +163 -0
- getinvoke/modes/__init__.py +5 -0
- getinvoke/modes/prompts.py +253 -0
- getinvoke/modes/registry.py +84 -0
- getinvoke/overlay.py +175 -0
- getinvoke/recorder.py +112 -0
- getinvoke/reformatter.py +262 -0
- getinvoke/settings_gui.py +503 -0
- getinvoke/setup_cli.py +221 -0
- getinvoke/terminal.py +144 -0
- getinvoke/transcriber.py +108 -0
- getinvoke/tray.py +347 -0
- getinvoke/updater.py +233 -0
- getinvoke/window.py +147 -0
- getinvoke/wizard.py +484 -0
- getinvoke-0.5.3.dist-info/METADATA +312 -0
- getinvoke-0.5.3.dist-info/RECORD +34 -0
- getinvoke-0.5.3.dist-info/WHEEL +5 -0
- getinvoke-0.5.3.dist-info/entry_points.txt +5 -0
- getinvoke-0.5.3.dist-info/licenses/LICENSE +21 -0
- getinvoke-0.5.3.dist-info/top_level.txt +1 -0
getinvoke/__init__.py
ADDED
getinvoke/__main__.py
ADDED
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
|