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/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
- Run 'meeting-noter' to launch the GUI, or use subcommands:
43
- - setup: One-time setup (Screen Recording permission)
44
- - start <name>: Interactive foreground recording
45
- - gui: Launch desktop GUI
46
- - menubar: Launch menu bar app
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 - launch GUI in background
50
- _launch_gui_background()
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
- run_foreground_capture(
105
- output_dir=output_dir,
106
- meeting_name=meeting_name,
107
- auto_transcribe=config.auto_transcribe,
108
- whisper_model=config.whisper_model,
109
- transcripts_dir=config.transcripts_dir,
110
- silence_timeout_minutes=config.silence_timeout,
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
- "--output-dir", "-o",
117
- type=click.Path(),
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 the background daemon to capture meeting audio.
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
- """Check if the daemon is running."""
157
- from meeting_noter.daemon import check_status
158
- check_status(DEFAULT_PID_FILE)
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
- @require_setup
163
- def stop():
164
- """Stop the running daemon."""
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
- stop_daemon(DEFAULT_PID_FILE)
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], live: bool):
436
+ def transcribe(file: Optional[str], output_dir: Optional[str], model: Optional[str]):
213
437
  """Transcribe a meeting recording.
214
438
 
215
- If no FILE is specified, transcribes the most recent recording.
216
- Use --live for real-time transcription of an ongoing meeting.
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, transcribe_live
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
- if live:
225
- transcribe_live(output_path, whisper_model)
226
- else:
227
- transcribe_file(file, output_path, whisper_model, config.transcripts_dir)
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
- def devices():
288
- """List available audio devices."""
289
- import sounddevice as sd
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
- for i, device in enumerate(devices):
295
- device_type = []
296
- if device["max_input_channels"] > 0:
297
- device_type.append("IN")
298
- if device["max_output_channels"] > 0:
299
- device_type.append("OUT")
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
- type_str = "/".join(device_type) if device_type else "N/A"
302
- click.echo(f" [{i}] {device['name']} ({type_str})")
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()