meeting-noter 0.6.1__py3-none-any.whl → 1.0.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.
Potentially problematic release.
This version of meeting-noter might be problematic. Click here for more details.
- meeting_noter/audio/encoder.py +8 -3
- meeting_noter/cli.py +773 -101
- meeting_noter/daemon.py +100 -16
- meeting_noter/meeting_detector.py +97 -60
- meeting_noter/mic_monitor.py +77 -22
- meeting_noter/transcription/live_transcription.py +250 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/METADATA +14 -3
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/RECORD +11 -10
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/entry_points.txt +1 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-0.6.1.dist-info → meeting_noter-1.0.0.dist-info}/top_level.txt +0 -0
meeting_noter/cli.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import difflib
|
|
5
6
|
import click
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Optional
|
|
@@ -19,6 +20,76 @@ from meeting_noter.config import (
|
|
|
19
20
|
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
class SuggestGroup(click.Group):
|
|
24
|
+
"""Custom Click group that suggests similar commands on typos."""
|
|
25
|
+
|
|
26
|
+
# Use SuggestCommand for all commands in this group
|
|
27
|
+
command_class = None # Will be set after SuggestCommand is defined
|
|
28
|
+
|
|
29
|
+
def resolve_command(self, ctx, args):
|
|
30
|
+
"""Override to suggest similar commands on error."""
|
|
31
|
+
try:
|
|
32
|
+
return super().resolve_command(ctx, args)
|
|
33
|
+
except click.UsageError as e:
|
|
34
|
+
if args:
|
|
35
|
+
cmd_name = args[0]
|
|
36
|
+
# Find similar commands
|
|
37
|
+
matches = difflib.get_close_matches(
|
|
38
|
+
cmd_name, self.list_commands(ctx), n=3, cutoff=0.5
|
|
39
|
+
)
|
|
40
|
+
if matches:
|
|
41
|
+
suggestion = f"\n\nDid you mean: {', '.join(matches)}?"
|
|
42
|
+
raise click.UsageError(str(e) + suggestion)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
def get_command(self, ctx, cmd_name):
|
|
46
|
+
"""Override to handle partial command matching."""
|
|
47
|
+
# Try exact match first
|
|
48
|
+
rv = super().get_command(ctx, cmd_name)
|
|
49
|
+
if rv is not None:
|
|
50
|
+
return rv
|
|
51
|
+
|
|
52
|
+
# Try prefix match
|
|
53
|
+
matches = [cmd for cmd in self.list_commands(ctx) if cmd.startswith(cmd_name)]
|
|
54
|
+
if len(matches) == 1:
|
|
55
|
+
return super().get_command(ctx, matches[0])
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SuggestCommand(click.Command):
|
|
61
|
+
"""Custom Click command that suggests similar options on typos."""
|
|
62
|
+
|
|
63
|
+
def make_context(self, info_name, args, parent=None, **extra):
|
|
64
|
+
"""Override to catch and improve option errors."""
|
|
65
|
+
try:
|
|
66
|
+
return super().make_context(info_name, args, parent, **extra)
|
|
67
|
+
except click.UsageError as e:
|
|
68
|
+
error_msg = str(e)
|
|
69
|
+
if "No such option:" in error_msg:
|
|
70
|
+
# Extract the bad option
|
|
71
|
+
bad_opt = error_msg.split("No such option:")[-1].strip()
|
|
72
|
+
# Get available options
|
|
73
|
+
all_opts = []
|
|
74
|
+
for param in self.params:
|
|
75
|
+
all_opts.extend(param.opts)
|
|
76
|
+
|
|
77
|
+
matches = difflib.get_close_matches(bad_opt, all_opts, n=3, cutoff=0.4)
|
|
78
|
+
if matches:
|
|
79
|
+
suggestion = f"\n\nDid you mean: {', '.join(matches)}?"
|
|
80
|
+
raise click.UsageError(error_msg + suggestion)
|
|
81
|
+
else:
|
|
82
|
+
# Show available options
|
|
83
|
+
raise click.UsageError(
|
|
84
|
+
error_msg + f"\n\nAvailable options: {', '.join(sorted(all_opts))}"
|
|
85
|
+
)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Set the default command class for SuggestGroup
|
|
90
|
+
SuggestGroup.command_class = SuggestCommand
|
|
91
|
+
|
|
92
|
+
|
|
22
93
|
def _launch_gui_background():
|
|
23
94
|
"""Launch the GUI in background and return immediately."""
|
|
24
95
|
import subprocess
|
|
@@ -33,53 +104,36 @@ def _launch_gui_background():
|
|
|
33
104
|
click.echo("Meeting Noter GUI launched.")
|
|
34
105
|
|
|
35
106
|
|
|
36
|
-
@click.group(invoke_without_command=True)
|
|
107
|
+
@click.group(cls=SuggestGroup, invoke_without_command=True)
|
|
37
108
|
@click.version_option(version=__version__)
|
|
38
109
|
@click.pass_context
|
|
39
110
|
def cli(ctx):
|
|
40
111
|
"""Meeting Noter - Offline meeting transcription.
|
|
41
112
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
113
|
+
\b
|
|
114
|
+
Quick start:
|
|
115
|
+
meeting-noter Start watching for meetings (background)
|
|
116
|
+
meeting-noter status Show current status
|
|
117
|
+
meeting-noter shutdown Stop all processes
|
|
118
|
+
meeting-noter open Open recordings in Finder
|
|
119
|
+
|
|
120
|
+
\b
|
|
121
|
+
Configuration:
|
|
122
|
+
meeting-noter config Show all settings
|
|
123
|
+
meeting-noter config recordings-dir ~/path Set recordings directory
|
|
124
|
+
meeting-noter config whisper-model base.en Set transcription model
|
|
125
|
+
meeting-noter config auto-transcribe false Disable auto-transcribe
|
|
47
126
|
"""
|
|
48
127
|
if ctx.invoked_subcommand is None:
|
|
49
|
-
# No subcommand -
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@cli.command()
|
|
54
|
-
def setup():
|
|
55
|
-
"""Set up Meeting Noter and initialize configuration.
|
|
56
|
-
|
|
57
|
-
This is a one-time setup that:
|
|
58
|
-
1. Requests Screen Recording permission (for capturing meeting audio)
|
|
59
|
-
2. Initializes configuration file
|
|
60
|
-
3. Creates recording directories
|
|
61
|
-
"""
|
|
62
|
-
from meeting_noter.install.macos import run_setup
|
|
63
|
-
|
|
64
|
-
config = get_config()
|
|
65
|
-
|
|
66
|
-
# Run the setup
|
|
67
|
-
run_setup()
|
|
68
|
-
|
|
69
|
-
# Mark setup as complete and ensure directories exist
|
|
70
|
-
config.setup_complete = True
|
|
71
|
-
config.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
-
config.transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
-
config.save()
|
|
74
|
-
|
|
75
|
-
click.echo(f"Recordings will be saved to: {config.recordings_dir}")
|
|
76
|
-
click.echo(f"Whisper model: {config.whisper_model}")
|
|
128
|
+
# No subcommand - start background watcher
|
|
129
|
+
ctx.invoke(watcher)
|
|
77
130
|
|
|
78
131
|
|
|
79
132
|
@cli.command()
|
|
80
133
|
@click.argument("name", required=False)
|
|
134
|
+
@click.option("--live", "-l", is_flag=True, help="Show live transcription in terminal")
|
|
81
135
|
@require_setup
|
|
82
|
-
def start(name: Optional[str]):
|
|
136
|
+
def start(name: Optional[str], live: bool):
|
|
83
137
|
"""Start an interactive foreground recording session.
|
|
84
138
|
|
|
85
139
|
NAME is the meeting name (optional). If not provided, uses a timestamp
|
|
@@ -88,11 +142,14 @@ def start(name: Optional[str]):
|
|
|
88
142
|
Examples:
|
|
89
143
|
meeting-noter start # Uses timestamp name
|
|
90
144
|
meeting-noter start "Weekly Standup" # Uses custom name
|
|
145
|
+
meeting-noter start "Meeting" --live # With live transcription
|
|
91
146
|
|
|
92
147
|
Press Ctrl+C to stop recording. The recording will be automatically
|
|
93
148
|
transcribed if auto_transcribe is enabled in settings.
|
|
94
149
|
"""
|
|
95
150
|
from meeting_noter.daemon import run_foreground_capture
|
|
151
|
+
import threading
|
|
152
|
+
import time
|
|
96
153
|
|
|
97
154
|
config = get_config()
|
|
98
155
|
output_dir = config.recordings_dir
|
|
@@ -101,41 +158,68 @@ def start(name: Optional[str]):
|
|
|
101
158
|
# Use default timestamp name if not provided
|
|
102
159
|
meeting_name = name if name else generate_meeting_name()
|
|
103
160
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
161
|
+
# Live transcription display thread
|
|
162
|
+
stop_live_display = threading.Event()
|
|
163
|
+
|
|
164
|
+
def display_live_transcript():
|
|
165
|
+
"""Background thread to display live transcription."""
|
|
166
|
+
live_dir = output_dir / "live"
|
|
167
|
+
last_content = ""
|
|
168
|
+
|
|
169
|
+
# Wait for live file to appear
|
|
170
|
+
while not stop_live_display.is_set():
|
|
171
|
+
live_files = list(live_dir.glob("*.live.txt")) if live_dir.exists() else []
|
|
172
|
+
if live_files:
|
|
173
|
+
live_file = max(live_files, key=lambda p: p.stat().st_mtime)
|
|
174
|
+
break
|
|
175
|
+
time.sleep(0.5)
|
|
176
|
+
else:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Tail the file
|
|
180
|
+
while not stop_live_display.is_set():
|
|
181
|
+
try:
|
|
182
|
+
content = live_file.read_text()
|
|
183
|
+
if len(content) > len(last_content):
|
|
184
|
+
new_content = content[len(last_content):]
|
|
185
|
+
for line in new_content.splitlines():
|
|
186
|
+
# Only show timestamp lines (transcriptions)
|
|
187
|
+
if line.strip() and line.startswith("["):
|
|
188
|
+
click.echo(click.style(line, fg="cyan"))
|
|
189
|
+
last_content = content
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
time.sleep(0.5)
|
|
193
|
+
|
|
194
|
+
# Start live display thread if requested
|
|
195
|
+
live_thread = None
|
|
196
|
+
if live:
|
|
197
|
+
click.echo(click.style("Live transcription enabled", fg="cyan"))
|
|
198
|
+
live_thread = threading.Thread(target=display_live_transcript, daemon=True)
|
|
199
|
+
live_thread.start()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
run_foreground_capture(
|
|
203
|
+
output_dir=output_dir,
|
|
204
|
+
meeting_name=meeting_name,
|
|
205
|
+
auto_transcribe=config.auto_transcribe,
|
|
206
|
+
whisper_model=config.whisper_model,
|
|
207
|
+
transcripts_dir=config.transcripts_dir,
|
|
208
|
+
silence_timeout_minutes=config.silence_timeout,
|
|
209
|
+
)
|
|
210
|
+
finally:
|
|
211
|
+
stop_live_display.set()
|
|
212
|
+
if live_thread:
|
|
213
|
+
live_thread.join(timeout=1.0)
|
|
112
214
|
|
|
113
215
|
|
|
114
|
-
@cli.command()
|
|
115
|
-
@click.option(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
default=None,
|
|
119
|
-
help="Directory to save recordings (overrides config)",
|
|
120
|
-
)
|
|
121
|
-
@click.option(
|
|
122
|
-
"--foreground", "-f",
|
|
123
|
-
is_flag=True,
|
|
124
|
-
help="Run in foreground instead of as daemon",
|
|
125
|
-
)
|
|
126
|
-
@click.option(
|
|
127
|
-
"--name", "-n",
|
|
128
|
-
default=None,
|
|
129
|
-
help="Meeting name for the recording",
|
|
130
|
-
)
|
|
216
|
+
@cli.command(hidden=True) # Internal command used by watcher
|
|
217
|
+
@click.option("--output-dir", "-o", type=click.Path(), default=None)
|
|
218
|
+
@click.option("--foreground", "-f", is_flag=True)
|
|
219
|
+
@click.option("--name", "-n", default=None)
|
|
131
220
|
@require_setup
|
|
132
221
|
def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
|
|
133
|
-
"""Start
|
|
134
|
-
|
|
135
|
-
The daemon captures your microphone and system audio (via ScreenCaptureKit)
|
|
136
|
-
and records to MP3 files. Files are automatically segmented when
|
|
137
|
-
silence is detected (indicating a meeting has ended).
|
|
138
|
-
"""
|
|
222
|
+
"""Internal: Start recording daemon."""
|
|
139
223
|
from meeting_noter.daemon import run_daemon
|
|
140
224
|
|
|
141
225
|
config = get_config()
|
|
@@ -151,19 +235,158 @@ def daemon(output_dir: Optional[str], foreground: bool, name: Optional[str]):
|
|
|
151
235
|
|
|
152
236
|
|
|
153
237
|
@cli.command()
|
|
154
|
-
@require_setup
|
|
155
238
|
def status():
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
|
|
239
|
+
"""Show Meeting Noter status."""
|
|
240
|
+
import os
|
|
241
|
+
from meeting_noter.daemon import read_pid_file, is_process_running
|
|
242
|
+
|
|
243
|
+
# Check watcher
|
|
244
|
+
watcher_running = False
|
|
245
|
+
if WATCHER_PID_FILE.exists():
|
|
246
|
+
try:
|
|
247
|
+
pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
248
|
+
os.kill(pid, 0)
|
|
249
|
+
watcher_running = True
|
|
250
|
+
except (ProcessLookupError, ValueError, FileNotFoundError):
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
# Check menubar
|
|
254
|
+
menubar_running = False
|
|
255
|
+
menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
|
|
256
|
+
if menubar_pid_file.exists():
|
|
257
|
+
try:
|
|
258
|
+
pid = int(menubar_pid_file.read_text().strip())
|
|
259
|
+
os.kill(pid, 0)
|
|
260
|
+
menubar_running = True
|
|
261
|
+
except (ProcessLookupError, ValueError, FileNotFoundError):
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
# Check daemon (recording)
|
|
265
|
+
daemon_running = False
|
|
266
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
267
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
268
|
+
daemon_running = True
|
|
269
|
+
|
|
270
|
+
# Determine status
|
|
271
|
+
click.echo()
|
|
272
|
+
if daemon_running:
|
|
273
|
+
# Get current recording name from log
|
|
274
|
+
recording_name = _get_current_recording_name()
|
|
275
|
+
click.echo(f"🔴 Recording: {recording_name or 'In progress'}")
|
|
276
|
+
elif watcher_running or menubar_running:
|
|
277
|
+
mode = "watcher" if watcher_running else "menubar"
|
|
278
|
+
click.echo(f"👀 Ready to record ({mode} active)")
|
|
279
|
+
else:
|
|
280
|
+
click.echo("⏹️ Stopped (run 'meeting-noter' to start)")
|
|
281
|
+
|
|
282
|
+
click.echo()
|
|
283
|
+
|
|
284
|
+
# Show details
|
|
285
|
+
click.echo("Components:")
|
|
286
|
+
click.echo(f" Watcher: {'running' if watcher_running else 'stopped'}")
|
|
287
|
+
click.echo(f" Menubar: {'running' if menubar_running else 'stopped'}")
|
|
288
|
+
click.echo(f" Recorder: {'recording' if daemon_running else 'idle'}")
|
|
289
|
+
click.echo()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _get_current_recording_name() -> str | None:
|
|
293
|
+
"""Get the name of the current recording from the log file."""
|
|
294
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
295
|
+
if not log_path.exists():
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
with open(log_path, "r") as f:
|
|
300
|
+
lines = f.readlines()
|
|
301
|
+
|
|
302
|
+
for line in reversed(lines[-50:]):
|
|
303
|
+
if "Recording started:" in line:
|
|
304
|
+
parts = line.split("Recording started:")
|
|
305
|
+
if len(parts) > 1:
|
|
306
|
+
filename = parts[1].strip().replace(".mp3", "")
|
|
307
|
+
# Extract name from timestamp_name format
|
|
308
|
+
name_parts = filename.split("_", 2)
|
|
309
|
+
if len(name_parts) >= 3:
|
|
310
|
+
return name_parts[2]
|
|
311
|
+
return filename
|
|
312
|
+
elif "Recording saved:" in line or "Recording discarded" in line:
|
|
313
|
+
break
|
|
314
|
+
return None
|
|
315
|
+
except Exception:
|
|
316
|
+
return None
|
|
159
317
|
|
|
160
318
|
|
|
161
319
|
@cli.command()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
320
|
+
def shutdown():
|
|
321
|
+
"""Stop all Meeting Noter processes (daemon, watcher, menubar, GUI)."""
|
|
322
|
+
import subprocess
|
|
323
|
+
import os
|
|
324
|
+
import signal
|
|
165
325
|
from meeting_noter.daemon import stop_daemon
|
|
166
|
-
|
|
326
|
+
|
|
327
|
+
stopped = []
|
|
328
|
+
|
|
329
|
+
# Stop daemon
|
|
330
|
+
if DEFAULT_PID_FILE.exists():
|
|
331
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
332
|
+
stopped.append("daemon")
|
|
333
|
+
|
|
334
|
+
# Stop watcher
|
|
335
|
+
if WATCHER_PID_FILE.exists():
|
|
336
|
+
try:
|
|
337
|
+
pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
338
|
+
os.kill(pid, signal.SIGTERM)
|
|
339
|
+
WATCHER_PID_FILE.unlink()
|
|
340
|
+
stopped.append("watcher")
|
|
341
|
+
except (ProcessLookupError, ValueError):
|
|
342
|
+
WATCHER_PID_FILE.unlink(missing_ok=True)
|
|
343
|
+
|
|
344
|
+
# Stop menubar
|
|
345
|
+
menubar_pid_file = Path.home() / ".meeting-noter-menubar.pid"
|
|
346
|
+
if menubar_pid_file.exists():
|
|
347
|
+
try:
|
|
348
|
+
pid = int(menubar_pid_file.read_text().strip())
|
|
349
|
+
os.kill(pid, signal.SIGTERM)
|
|
350
|
+
menubar_pid_file.unlink()
|
|
351
|
+
stopped.append("menubar")
|
|
352
|
+
except (ProcessLookupError, ValueError):
|
|
353
|
+
menubar_pid_file.unlink(missing_ok=True)
|
|
354
|
+
|
|
355
|
+
# Kill any remaining meeting-noter processes
|
|
356
|
+
result = subprocess.run(
|
|
357
|
+
["pkill", "-f", "meeting_noter"],
|
|
358
|
+
capture_output=True
|
|
359
|
+
)
|
|
360
|
+
if result.returncode == 0:
|
|
361
|
+
stopped.append("other processes")
|
|
362
|
+
|
|
363
|
+
if stopped:
|
|
364
|
+
click.echo(f"Stopped: {', '.join(stopped)}")
|
|
365
|
+
else:
|
|
366
|
+
click.echo("No Meeting Noter processes were running.")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@cli.command()
|
|
370
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output (like tail -f)")
|
|
371
|
+
@click.option("--lines", "-n", default=50, help="Number of lines to show")
|
|
372
|
+
def logs(follow: bool, lines: int):
|
|
373
|
+
"""View Meeting Noter logs."""
|
|
374
|
+
import subprocess
|
|
375
|
+
|
|
376
|
+
log_file = Path.home() / ".meeting-noter.log"
|
|
377
|
+
|
|
378
|
+
if not log_file.exists():
|
|
379
|
+
click.echo("No log file found.")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
if follow:
|
|
383
|
+
click.echo(f"Following {log_file} (Ctrl+C to stop)...")
|
|
384
|
+
try:
|
|
385
|
+
subprocess.run(["tail", "-f", str(log_file)])
|
|
386
|
+
except KeyboardInterrupt:
|
|
387
|
+
pass
|
|
388
|
+
else:
|
|
389
|
+
subprocess.run(["tail", f"-{lines}", str(log_file)])
|
|
167
390
|
|
|
168
391
|
|
|
169
392
|
@cli.command("list")
|
|
@@ -181,7 +404,13 @@ def stop():
|
|
|
181
404
|
)
|
|
182
405
|
@require_setup
|
|
183
406
|
def list_recordings(output_dir: Optional[str], limit: int):
|
|
184
|
-
"""List recent meeting recordings.
|
|
407
|
+
"""List recent meeting recordings.
|
|
408
|
+
|
|
409
|
+
\b
|
|
410
|
+
Examples:
|
|
411
|
+
meeting-noter list # Show last 10 recordings
|
|
412
|
+
meeting-noter list -n 20 # Show last 20 recordings
|
|
413
|
+
"""
|
|
185
414
|
from meeting_noter.output.writer import list_recordings as _list_recordings
|
|
186
415
|
|
|
187
416
|
config = get_config()
|
|
@@ -203,28 +432,116 @@ def list_recordings(output_dir: Optional[str], limit: int):
|
|
|
203
432
|
default=None,
|
|
204
433
|
help="Whisper model size (overrides config)",
|
|
205
434
|
)
|
|
206
|
-
@click.option(
|
|
207
|
-
"--live", "-l",
|
|
208
|
-
is_flag=True,
|
|
209
|
-
help="Real-time transcription of current recording",
|
|
210
|
-
)
|
|
211
435
|
@require_setup
|
|
212
|
-
def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str]
|
|
436
|
+
def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str]):
|
|
213
437
|
"""Transcribe a meeting recording.
|
|
214
438
|
|
|
215
|
-
|
|
216
|
-
|
|
439
|
+
\b
|
|
440
|
+
Examples:
|
|
441
|
+
meeting-noter transcribe # Transcribe latest recording
|
|
442
|
+
meeting-noter transcribe recording.mp3 # Transcribe specific file
|
|
443
|
+
meeting-noter transcribe -m base.en # Use larger model for accuracy
|
|
217
444
|
"""
|
|
218
|
-
from meeting_noter.transcription.engine import transcribe_file
|
|
445
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
219
446
|
|
|
220
447
|
config = get_config()
|
|
221
448
|
output_path = Path(output_dir) if output_dir else config.recordings_dir
|
|
222
449
|
whisper_model = model or config.whisper_model
|
|
223
450
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
451
|
+
transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@cli.command()
|
|
455
|
+
@require_setup
|
|
456
|
+
def live():
|
|
457
|
+
"""Show live transcription of an active recording.
|
|
458
|
+
|
|
459
|
+
Displays the real-time transcript as it's being generated.
|
|
460
|
+
Use in a separate terminal while recording with 'meeting-noter start'.
|
|
461
|
+
|
|
462
|
+
\b
|
|
463
|
+
Examples:
|
|
464
|
+
# Terminal 1: Start recording
|
|
465
|
+
meeting-noter start "Team Meeting"
|
|
466
|
+
|
|
467
|
+
# Terminal 2: Watch live transcript
|
|
468
|
+
meeting-noter live
|
|
469
|
+
|
|
470
|
+
Or use 'meeting-noter start "name" --live' to see both in one terminal.
|
|
471
|
+
"""
|
|
472
|
+
import time
|
|
473
|
+
|
|
474
|
+
config = get_config()
|
|
475
|
+
live_dir = config.recordings_dir / "live"
|
|
476
|
+
|
|
477
|
+
# Find the most recent .live.txt file in the live/ subfolder
|
|
478
|
+
if not live_dir.exists():
|
|
479
|
+
click.echo(click.style("No live transcript found.", fg="yellow"))
|
|
480
|
+
click.echo("Start a recording with: meeting-noter start")
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
live_files = sorted(
|
|
484
|
+
live_dir.glob("*.live.txt"),
|
|
485
|
+
key=lambda p: p.stat().st_mtime,
|
|
486
|
+
reverse=True,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if not live_files:
|
|
490
|
+
click.echo(click.style("No live transcript found.", fg="yellow"))
|
|
491
|
+
click.echo("Start a recording with: meeting-noter start")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
live_file = live_files[0]
|
|
495
|
+
|
|
496
|
+
# Check if file is actively being written (modified in last 30 seconds)
|
|
497
|
+
file_age = time.time() - live_file.stat().st_mtime
|
|
498
|
+
if file_age > 30:
|
|
499
|
+
click.echo(click.style("No active recording found.", fg="yellow"))
|
|
500
|
+
click.echo(f"Most recent transcript ({live_file.name}) is {int(file_age)}s old.")
|
|
501
|
+
click.echo("Start a recording with: meeting-noter start")
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
click.echo(click.style("Live Transcription", fg="cyan", bold=True))
|
|
505
|
+
click.echo(f"Source: {live_file.name.replace('.live.txt', '.mp3')}")
|
|
506
|
+
click.echo("Press Ctrl+C to stop watching.\n")
|
|
507
|
+
click.echo("-" * 40)
|
|
508
|
+
|
|
509
|
+
# Tail the file
|
|
510
|
+
try:
|
|
511
|
+
last_content = ""
|
|
512
|
+
no_update_count = 0
|
|
513
|
+
|
|
514
|
+
while True:
|
|
515
|
+
try:
|
|
516
|
+
with open(live_file, "r") as f:
|
|
517
|
+
content = f.read()
|
|
518
|
+
|
|
519
|
+
# Print only new content
|
|
520
|
+
if len(content) > len(last_content):
|
|
521
|
+
new_content = content[len(last_content):]
|
|
522
|
+
# Print line by line for better formatting
|
|
523
|
+
for line in new_content.splitlines():
|
|
524
|
+
if line.strip():
|
|
525
|
+
click.echo(line)
|
|
526
|
+
last_content = content
|
|
527
|
+
no_update_count = 0
|
|
528
|
+
else:
|
|
529
|
+
no_update_count += 1
|
|
530
|
+
|
|
531
|
+
# Check if file hasn't been updated for 30+ seconds (recording likely ended)
|
|
532
|
+
file_age = time.time() - live_file.stat().st_mtime
|
|
533
|
+
if file_age > 30 and no_update_count > 5:
|
|
534
|
+
click.echo("\n" + click.style("Recording ended.", fg="yellow"))
|
|
535
|
+
break
|
|
536
|
+
|
|
537
|
+
except FileNotFoundError:
|
|
538
|
+
click.echo("\n" + click.style("Live transcript file removed.", fg="yellow"))
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
time.sleep(1)
|
|
542
|
+
|
|
543
|
+
except KeyboardInterrupt:
|
|
544
|
+
click.echo("\n" + click.style("Stopped watching.", fg="cyan"))
|
|
228
545
|
|
|
229
546
|
|
|
230
547
|
@cli.command()
|
|
@@ -283,26 +600,381 @@ def gui(foreground: bool):
|
|
|
283
600
|
_launch_gui_background()
|
|
284
601
|
|
|
285
602
|
|
|
603
|
+
# Config key mappings (CLI name -> config attribute)
|
|
604
|
+
CONFIG_KEYS = {
|
|
605
|
+
"recordings-dir": ("recordings_dir", "path", "Directory for audio recordings"),
|
|
606
|
+
"transcripts-dir": ("transcripts_dir", "path", "Directory for transcripts"),
|
|
607
|
+
"whisper-model": ("whisper_model", "choice:tiny.en,base.en,small.en,medium.en,large-v3", "Whisper model for transcription"),
|
|
608
|
+
"auto-transcribe": ("auto_transcribe", "bool", "Auto-transcribe after recording"),
|
|
609
|
+
"silence-timeout": ("silence_timeout", "int", "Minutes of silence before auto-stop"),
|
|
610
|
+
"capture-system-audio": ("capture_system_audio", "bool", "Capture meeting participants via ScreenCaptureKit"),
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
286
614
|
@cli.command()
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
615
|
+
@click.argument("key", required=False)
|
|
616
|
+
@click.argument("value", required=False)
|
|
617
|
+
def config(key: Optional[str], value: Optional[str]):
|
|
618
|
+
"""View or set configuration options.
|
|
619
|
+
|
|
620
|
+
\b
|
|
621
|
+
Examples:
|
|
622
|
+
meeting-noter config # Show all settings
|
|
623
|
+
meeting-noter config recordings-dir # Get specific setting
|
|
624
|
+
meeting-noter config recordings-dir ~/meetings # Set setting
|
|
625
|
+
|
|
626
|
+
\b
|
|
627
|
+
Available settings:
|
|
628
|
+
recordings-dir Directory for audio recordings
|
|
629
|
+
transcripts-dir Directory for transcripts
|
|
630
|
+
whisper-model Model: tiny.en, base.en, small.en, medium.en, large-v3
|
|
631
|
+
auto-transcribe Auto-transcribe after recording (true/false)
|
|
632
|
+
silence-timeout Minutes of silence before auto-stop
|
|
633
|
+
capture-system-audio Capture system audio (true/false)
|
|
634
|
+
"""
|
|
635
|
+
cfg = get_config()
|
|
636
|
+
|
|
637
|
+
if key is None:
|
|
638
|
+
# Show all settings
|
|
639
|
+
click.echo()
|
|
640
|
+
click.echo("Meeting Noter Configuration")
|
|
641
|
+
click.echo("=" * 40)
|
|
642
|
+
for cli_key, (attr, _, desc) in CONFIG_KEYS.items():
|
|
643
|
+
val = getattr(cfg, attr)
|
|
644
|
+
click.echo(f" {cli_key}: {val}")
|
|
645
|
+
click.echo()
|
|
646
|
+
click.echo(f"Config file: {cfg.config_path}")
|
|
647
|
+
click.echo()
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
# Normalize key (allow underscores too)
|
|
651
|
+
key = key.replace("_", "-").lower()
|
|
652
|
+
|
|
653
|
+
if key not in CONFIG_KEYS:
|
|
654
|
+
click.echo(f"Unknown config key: {key}")
|
|
655
|
+
click.echo(f"Available keys: {', '.join(CONFIG_KEYS.keys())}")
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
attr, val_type, desc = CONFIG_KEYS[key]
|
|
659
|
+
|
|
660
|
+
if value is None:
|
|
661
|
+
# Get setting
|
|
662
|
+
click.echo(getattr(cfg, attr))
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
# Set setting
|
|
666
|
+
try:
|
|
667
|
+
if val_type == "bool":
|
|
668
|
+
if value.lower() in ("true", "1", "yes", "on"):
|
|
669
|
+
parsed = True
|
|
670
|
+
elif value.lower() in ("false", "0", "no", "off"):
|
|
671
|
+
parsed = False
|
|
672
|
+
else:
|
|
673
|
+
raise ValueError(f"Invalid boolean: {value}")
|
|
674
|
+
elif val_type == "int":
|
|
675
|
+
parsed = int(value)
|
|
676
|
+
elif val_type == "path":
|
|
677
|
+
parsed = Path(value).expanduser()
|
|
678
|
+
# Create directory if it doesn't exist
|
|
679
|
+
parsed.mkdir(parents=True, exist_ok=True)
|
|
680
|
+
elif val_type.startswith("choice:"):
|
|
681
|
+
choices = val_type.split(":")[1].split(",")
|
|
682
|
+
if value not in choices:
|
|
683
|
+
raise ValueError(f"Must be one of: {', '.join(choices)}")
|
|
684
|
+
parsed = value
|
|
685
|
+
else:
|
|
686
|
+
parsed = value
|
|
687
|
+
|
|
688
|
+
setattr(cfg, attr, parsed)
|
|
689
|
+
cfg.save()
|
|
690
|
+
click.echo(f"Set {key} = {parsed}")
|
|
691
|
+
|
|
692
|
+
except ValueError as e:
|
|
693
|
+
click.echo(f"Invalid value: {e}")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@cli.command(hidden=True)
|
|
700
|
+
@click.option(
|
|
701
|
+
"--foreground", "-f",
|
|
702
|
+
is_flag=True,
|
|
703
|
+
help="Run in foreground instead of background",
|
|
704
|
+
)
|
|
705
|
+
@require_setup
|
|
706
|
+
def watcher(foreground: bool):
|
|
707
|
+
"""Start background watcher that auto-detects and records meetings.
|
|
708
|
+
|
|
709
|
+
This is the default command when running 'meeting-noter' without arguments.
|
|
710
|
+
Runs in background by default. Use 'meeting-noter shutdown' to stop.
|
|
711
|
+
|
|
712
|
+
Use -f/--foreground for interactive mode (shows prompts in terminal).
|
|
713
|
+
"""
|
|
714
|
+
import subprocess
|
|
715
|
+
import sys
|
|
716
|
+
import os
|
|
717
|
+
|
|
718
|
+
if foreground:
|
|
719
|
+
_run_watcher_loop()
|
|
720
|
+
else:
|
|
721
|
+
# Check if already running
|
|
722
|
+
if WATCHER_PID_FILE.exists():
|
|
723
|
+
try:
|
|
724
|
+
pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
725
|
+
os.kill(pid, 0) # Check if process exists
|
|
726
|
+
click.echo(f"Watcher already running (PID {pid}). Use 'meeting-noter shutdown' to stop.")
|
|
727
|
+
return
|
|
728
|
+
except (ProcessLookupError, ValueError):
|
|
729
|
+
WATCHER_PID_FILE.unlink(missing_ok=True)
|
|
730
|
+
|
|
731
|
+
# Start in background
|
|
732
|
+
subprocess.Popen(
|
|
733
|
+
[sys.executable, "-m", "meeting_noter.cli", "watcher", "-f"],
|
|
734
|
+
stdout=subprocess.DEVNULL,
|
|
735
|
+
stderr=subprocess.DEVNULL,
|
|
736
|
+
start_new_session=True,
|
|
737
|
+
)
|
|
738
|
+
click.echo("Meeting Noter watcher started in background.")
|
|
739
|
+
click.echo("Use 'meeting-noter shutdown' to stop.")
|
|
290
740
|
|
|
291
|
-
devices = sd.query_devices()
|
|
292
|
-
click.echo("\nAvailable Audio Devices:\n")
|
|
293
741
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
742
|
+
def _run_watcher_loop():
|
|
743
|
+
"""Run the watcher loop (foreground)."""
|
|
744
|
+
import time
|
|
745
|
+
import sys
|
|
746
|
+
import os
|
|
747
|
+
import atexit
|
|
748
|
+
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
|
|
749
|
+
from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
|
|
750
|
+
|
|
751
|
+
# Write PID file
|
|
752
|
+
WATCHER_PID_FILE.write_text(str(os.getpid()))
|
|
753
|
+
atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
|
|
300
754
|
|
|
301
|
-
|
|
302
|
-
|
|
755
|
+
config = get_config()
|
|
756
|
+
output_dir = config.recordings_dir
|
|
757
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
303
758
|
|
|
759
|
+
mic_monitor = MicrophoneMonitor()
|
|
760
|
+
current_meeting_name = None
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
while True:
|
|
764
|
+
mic_started, mic_stopped, app_name = mic_monitor.check()
|
|
765
|
+
|
|
766
|
+
is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
|
|
767
|
+
is_process_running(read_pid_file(DEFAULT_PID_FILE))
|
|
768
|
+
|
|
769
|
+
if mic_started and not is_recording:
|
|
770
|
+
# Meeting detected - auto-start recording silently
|
|
771
|
+
app_name = app_name or is_meeting_app_active() or "Unknown"
|
|
772
|
+
meeting_name = get_meeting_window_title() or generate_meeting_name()
|
|
773
|
+
current_meeting_name = meeting_name
|
|
774
|
+
|
|
775
|
+
# Start daemon
|
|
776
|
+
import subprocess
|
|
777
|
+
subprocess.Popen(
|
|
778
|
+
[sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
|
|
779
|
+
stdout=subprocess.DEVNULL,
|
|
780
|
+
stderr=subprocess.DEVNULL,
|
|
781
|
+
)
|
|
782
|
+
mic_monitor.set_recording(True, app_name)
|
|
783
|
+
|
|
784
|
+
elif mic_stopped and is_recording:
|
|
785
|
+
# Meeting ended - stop silently
|
|
786
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
787
|
+
mic_monitor.set_recording(False)
|
|
788
|
+
|
|
789
|
+
# Auto-transcribe
|
|
790
|
+
if config.auto_transcribe:
|
|
791
|
+
time.sleep(2)
|
|
792
|
+
mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
|
|
793
|
+
if mp3_files:
|
|
794
|
+
latest = mp3_files[-1]
|
|
795
|
+
import subprocess
|
|
796
|
+
subprocess.Popen(
|
|
797
|
+
[sys.executable, "-m", "meeting_noter.cli", "transcribe", str(latest)],
|
|
798
|
+
stdout=subprocess.DEVNULL,
|
|
799
|
+
stderr=subprocess.DEVNULL,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
current_meeting_name = None
|
|
803
|
+
|
|
804
|
+
time.sleep(2)
|
|
805
|
+
|
|
806
|
+
except KeyboardInterrupt:
|
|
807
|
+
if is_recording:
|
|
808
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@cli.command(hidden=True)
|
|
812
|
+
@require_setup
|
|
813
|
+
def watch():
|
|
814
|
+
"""Watch for meetings interactively (foreground with prompts).
|
|
815
|
+
|
|
816
|
+
Like 'meeting-noter' but runs in foreground and prompts before recording.
|
|
817
|
+
Press Ctrl+C to exit.
|
|
818
|
+
"""
|
|
819
|
+
import time
|
|
820
|
+
import sys
|
|
821
|
+
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
|
|
822
|
+
from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
|
|
823
|
+
|
|
824
|
+
config = get_config()
|
|
825
|
+
output_dir = config.recordings_dir
|
|
826
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
827
|
+
|
|
828
|
+
mic_monitor = MicrophoneMonitor()
|
|
829
|
+
current_meeting_name = None
|
|
830
|
+
|
|
831
|
+
click.echo("👀 Watching for meetings... (Ctrl+C to exit)")
|
|
304
832
|
click.echo()
|
|
305
833
|
|
|
834
|
+
try:
|
|
835
|
+
while True:
|
|
836
|
+
mic_started, mic_stopped, app_name = mic_monitor.check()
|
|
837
|
+
|
|
838
|
+
is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
|
|
839
|
+
is_process_running(read_pid_file(DEFAULT_PID_FILE))
|
|
840
|
+
|
|
841
|
+
if mic_started and not is_recording:
|
|
842
|
+
app_name = app_name or is_meeting_app_active() or "Unknown"
|
|
843
|
+
meeting_name = get_meeting_window_title() or generate_meeting_name()
|
|
844
|
+
|
|
845
|
+
click.echo(f"🎤 Meeting detected: {app_name}")
|
|
846
|
+
click.echo(f" Name: {meeting_name}")
|
|
847
|
+
|
|
848
|
+
if click.confirm(" Start recording?", default=True):
|
|
849
|
+
current_meeting_name = meeting_name
|
|
850
|
+
click.echo(f"🔴 Recording: {meeting_name}")
|
|
851
|
+
|
|
852
|
+
import subprocess
|
|
853
|
+
subprocess.Popen(
|
|
854
|
+
[sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
|
|
855
|
+
stdout=subprocess.DEVNULL,
|
|
856
|
+
stderr=subprocess.DEVNULL,
|
|
857
|
+
)
|
|
858
|
+
mic_monitor.set_recording(True, app_name)
|
|
859
|
+
else:
|
|
860
|
+
click.echo(" Skipped.")
|
|
861
|
+
mic_monitor._was_mic_in_use = True
|
|
862
|
+
|
|
863
|
+
click.echo()
|
|
864
|
+
|
|
865
|
+
elif mic_stopped and is_recording:
|
|
866
|
+
click.echo(f"📴 Meeting ended: {current_meeting_name or 'Unknown'}")
|
|
867
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
868
|
+
mic_monitor.set_recording(False)
|
|
869
|
+
current_meeting_name = None
|
|
870
|
+
|
|
871
|
+
if config.auto_transcribe:
|
|
872
|
+
click.echo("📝 Auto-transcribing...")
|
|
873
|
+
time.sleep(2)
|
|
874
|
+
mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda p: p.stat().st_mtime)
|
|
875
|
+
if mp3_files:
|
|
876
|
+
latest = mp3_files[-1]
|
|
877
|
+
click.echo(f" Transcribing: {latest.name}")
|
|
878
|
+
import subprocess
|
|
879
|
+
subprocess.Popen(
|
|
880
|
+
[sys.executable, "-m", "meeting_noter.cli", "transcribe", str(latest)],
|
|
881
|
+
stdout=subprocess.DEVNULL,
|
|
882
|
+
stderr=subprocess.DEVNULL,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
click.echo()
|
|
886
|
+
click.echo("👀 Watching for meetings... (Ctrl+C to exit)")
|
|
887
|
+
click.echo()
|
|
888
|
+
|
|
889
|
+
time.sleep(2)
|
|
890
|
+
|
|
891
|
+
except KeyboardInterrupt:
|
|
892
|
+
click.echo()
|
|
893
|
+
if is_recording:
|
|
894
|
+
click.echo("Stopping recording...")
|
|
895
|
+
stop_daemon(DEFAULT_PID_FILE)
|
|
896
|
+
click.echo("Stopped watching.")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@cli.command("open")
|
|
900
|
+
@click.argument("what", type=click.Choice(["recordings", "transcripts", "config"]), default="recordings")
|
|
901
|
+
def open_folder(what: str):
|
|
902
|
+
"""Open recordings, transcripts, or config folder in Finder.
|
|
903
|
+
|
|
904
|
+
\b
|
|
905
|
+
Examples:
|
|
906
|
+
meeting-noter open # Open recordings folder
|
|
907
|
+
meeting-noter open recordings # Open recordings folder
|
|
908
|
+
meeting-noter open transcripts # Open transcripts folder
|
|
909
|
+
meeting-noter open config # Open config folder
|
|
910
|
+
"""
|
|
911
|
+
import subprocess
|
|
912
|
+
|
|
913
|
+
config = get_config()
|
|
914
|
+
|
|
915
|
+
paths = {
|
|
916
|
+
"recordings": config.recordings_dir,
|
|
917
|
+
"transcripts": config.transcripts_dir,
|
|
918
|
+
"config": config.config_path.parent,
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
path = paths[what]
|
|
922
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
923
|
+
|
|
924
|
+
subprocess.run(["open", str(path)])
|
|
925
|
+
click.echo(f"Opened: {path}")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@cli.command()
|
|
929
|
+
@click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
|
|
930
|
+
def completion(shell: str):
|
|
931
|
+
"""Install shell tab completion.
|
|
932
|
+
|
|
933
|
+
\b
|
|
934
|
+
For zsh (default on macOS):
|
|
935
|
+
eval "$(_MEETING_NOTER_COMPLETE=zsh_source meeting-noter)"
|
|
936
|
+
|
|
937
|
+
Add to your ~/.zshrc for permanent completion.
|
|
938
|
+
"""
|
|
939
|
+
import os
|
|
940
|
+
|
|
941
|
+
shell_configs = {
|
|
942
|
+
"zsh": ("~/.zshrc", '_MEETING_NOTER_COMPLETE=zsh_source meeting-noter'),
|
|
943
|
+
"bash": ("~/.bashrc", '_MEETING_NOTER_COMPLETE=bash_source meeting-noter'),
|
|
944
|
+
"fish": ("~/.config/fish/completions/meeting-noter.fish", '_MEETING_NOTER_COMPLETE=fish_source meeting-noter'),
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
config_file, env_cmd = shell_configs[shell]
|
|
948
|
+
config_path = Path(config_file).expanduser()
|
|
949
|
+
|
|
950
|
+
completion_line = f'eval "$({env_cmd})"'
|
|
951
|
+
|
|
952
|
+
# Check if already installed
|
|
953
|
+
if config_path.exists():
|
|
954
|
+
content = config_path.read_text()
|
|
955
|
+
if "MEETING_NOTER_COMPLETE" in content:
|
|
956
|
+
click.echo(f"Completion already installed in {config_file}")
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
# Install
|
|
960
|
+
click.echo(f"Installing {shell} completion...")
|
|
961
|
+
|
|
962
|
+
if shell == "fish":
|
|
963
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
964
|
+
import subprocess
|
|
965
|
+
result = subprocess.run(
|
|
966
|
+
["sh", "-c", f"{env_cmd}"],
|
|
967
|
+
capture_output=True, text=True
|
|
968
|
+
)
|
|
969
|
+
config_path.write_text(result.stdout)
|
|
970
|
+
else:
|
|
971
|
+
with open(config_path, "a") as f:
|
|
972
|
+
f.write(f"\n# Meeting Noter tab completion\n{completion_line}\n")
|
|
973
|
+
|
|
974
|
+
click.echo(f"Added to {config_file}")
|
|
975
|
+
click.echo(f"Run: source {config_file}")
|
|
976
|
+
click.echo("Or restart your terminal.")
|
|
977
|
+
|
|
306
978
|
|
|
307
979
|
if __name__ == "__main__":
|
|
308
980
|
cli()
|