fow-cli 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (51) hide show
  1. {fow_cli-0.3.1 → fow_cli-0.4.0}/CHANGELOG.md +11 -0
  2. {fow_cli-0.3.1 → fow_cli-0.4.0}/PKG-INFO +39 -1
  3. {fow_cli-0.3.1 → fow_cli-0.4.0}/README.md +38 -0
  4. {fow_cli-0.3.1 → fow_cli-0.4.0}/pyproject.toml +1 -1
  5. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/__init__.py +1 -1
  6. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli.py +2 -0
  7. fow_cli-0.4.0/src/fly_on_the_wall/cli_service.py +235 -0
  8. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/glossary.py +8 -2
  9. {fow_cli-0.3.1 → fow_cli-0.4.0}/.gitignore +0 -0
  10. {fow_cli-0.3.1 → fow_cli-0.4.0}/LICENSE +0 -0
  11. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/api_keys.py +0 -0
  12. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/audio.py +0 -0
  13. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/audio_metadata.py +0 -0
  14. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cache.py +0 -0
  15. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cleanup.py +0 -0
  16. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_costs.py +0 -0
  17. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_glossary.py +0 -0
  18. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_menu.py +0 -0
  19. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_publish.py +0 -0
  20. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_speaker_review.py +0 -0
  21. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/cli_watch.py +0 -0
  22. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/config.py +0 -0
  23. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/costs.py +0 -0
  24. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/db.py +0 -0
  25. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/doctor.py +0 -0
  26. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/embeddings.py +0 -0
  27. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/exporting.py +0 -0
  28. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/meetings.py +0 -0
  29. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/normalization.py +0 -0
  30. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/people.py +0 -0
  31. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/people_embeddings.py +0 -0
  32. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/pipeline.py +0 -0
  33. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/processing.py +0 -0
  34. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/providers/__init__.py +0 -0
  35. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/providers/elevenlabs.py +0 -0
  36. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/providers/openai_analysis.py +0 -0
  37. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/providers/openai_cleanup.py +0 -0
  38. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/publishing.py +0 -0
  39. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/py.typed +0 -0
  40. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/reanalysis.py +0 -0
  41. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/recording_quality.py +0 -0
  42. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/rendering.py +0 -0
  43. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/secrets.py +0 -0
  44. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/service_pricing.py +0 -0
  45. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/setup.py +0 -0
  46. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/speaker_identity.py +0 -0
  47. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/speaker_matching.py +0 -0
  48. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/speakers.py +0 -0
  49. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/storage.py +0 -0
  50. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/voice_samples.py +0 -0
  51. {fow_cli-0.3.1 → fow_cli-0.4.0}/src/fly_on_the_wall/watch.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to Fly on the Wall are documented here.
4
4
 
5
+ ## [0.4.0] - 2026-07-02
6
+
7
+ ### Added
8
+
9
+ - Added `fow service` commands for installing and managing the Linux systemd user watch service.
10
+ - Added a generated `service.env` placeholder for background service environment variables.
11
+
12
+ ### Changed
13
+
14
+ - Made the Linux service install paths respect `XDG_CONFIG_HOME`.
15
+
5
16
  ## [0.3.1] - 2026-06-15
6
17
 
7
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fow-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Personal CLI note-taker for turning meeting audio into cleaned meeting manuscripts.
5
5
  Project-URL: Repository, https://github.com/henriksvensson/fly-on-the-wall
6
6
  License-Expression: MIT
@@ -329,6 +329,36 @@ fow watch run
329
329
 
330
330
  The watcher tolerates missing/remounted folders and uses periodic scans because cloud/removable mounts may not emit reliable filesystem events.
331
331
 
332
+ ### Background Service
333
+
334
+ On Linux with systemd user services, install the watcher as a background service:
335
+
336
+ ```bash
337
+ fow service install
338
+ ```
339
+
340
+ This writes `fow-watch.service` under the user systemd config directory, creates a non-overwriting placeholder `service.env` next to the app config, and reloads the user systemd manager. By default those paths are `~/.config/systemd/user/fow-watch.service` and `~/.config/fly-on-the-wall/service.env`; if `XDG_CONFIG_HOME` is set, the service installer uses that config root instead. It does not enable or start the service by default.
341
+
342
+ Enable it for future user sessions and start it now:
343
+
344
+ ```bash
345
+ fow service install --enable --start
346
+ ```
347
+
348
+ Starting from install refuses to run if no enabled watch folders are configured, because `fow watch run` would exit immediately. Add a folder first with `fow watch folders add <path>`, or pass `--force-start` if you intentionally want to start anyway.
349
+
350
+ Useful service commands:
351
+
352
+ ```bash
353
+ fow service status
354
+ fow service logs
355
+ fow service restart
356
+ fow service stop
357
+ fow service uninstall
358
+ ```
359
+
360
+ Optional service environment variables can be placed in the generated `service.env` file. The file is created as a commented placeholder during service install and is not overwritten by later installs.
361
+
332
362
  ## Publishing To Obsidian
333
363
 
334
364
  Publishing is separate from internal exports.
@@ -446,6 +476,14 @@ Run tests:
446
476
  uv run pytest
447
477
  ```
448
478
 
479
+ When developing a checkout while the background service is installed, stop the service first so it does not process files with the installed command:
480
+
481
+ ```bash
482
+ fow service stop
483
+ uv run fow
484
+ fow service start
485
+ ```
486
+
449
487
  Run lint and formatting checks:
450
488
 
451
489
  ```bash
@@ -299,6 +299,36 @@ fow watch run
299
299
 
300
300
  The watcher tolerates missing/remounted folders and uses periodic scans because cloud/removable mounts may not emit reliable filesystem events.
301
301
 
302
+ ### Background Service
303
+
304
+ On Linux with systemd user services, install the watcher as a background service:
305
+
306
+ ```bash
307
+ fow service install
308
+ ```
309
+
310
+ This writes `fow-watch.service` under the user systemd config directory, creates a non-overwriting placeholder `service.env` next to the app config, and reloads the user systemd manager. By default those paths are `~/.config/systemd/user/fow-watch.service` and `~/.config/fly-on-the-wall/service.env`; if `XDG_CONFIG_HOME` is set, the service installer uses that config root instead. It does not enable or start the service by default.
311
+
312
+ Enable it for future user sessions and start it now:
313
+
314
+ ```bash
315
+ fow service install --enable --start
316
+ ```
317
+
318
+ Starting from install refuses to run if no enabled watch folders are configured, because `fow watch run` would exit immediately. Add a folder first with `fow watch folders add <path>`, or pass `--force-start` if you intentionally want to start anyway.
319
+
320
+ Useful service commands:
321
+
322
+ ```bash
323
+ fow service status
324
+ fow service logs
325
+ fow service restart
326
+ fow service stop
327
+ fow service uninstall
328
+ ```
329
+
330
+ Optional service environment variables can be placed in the generated `service.env` file. The file is created as a commented placeholder during service install and is not overwritten by later installs.
331
+
302
332
  ## Publishing To Obsidian
303
333
 
304
334
  Publishing is separate from internal exports.
@@ -416,6 +446,14 @@ Run tests:
416
446
  uv run pytest
417
447
  ```
418
448
 
449
+ When developing a checkout while the background service is installed, stop the service first so it does not process files with the installed command:
450
+
451
+ ```bash
452
+ fow service stop
453
+ uv run fow
454
+ fow service start
455
+ ```
456
+
419
457
  Run lint and formatting checks:
420
458
 
421
459
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fow-cli"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Personal CLI note-taker for turning meeting audio into cleaned meeting manuscripts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """Fly on the Wall CLI application."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.0"
@@ -11,6 +11,7 @@ from fly_on_the_wall import __version__
11
11
  from fly_on_the_wall.cli_costs import costs_app
12
12
  from fly_on_the_wall.cli_glossary import glossary_app
13
13
  from fly_on_the_wall.cli_publish import publish_app
14
+ from fly_on_the_wall.cli_service import service_app
14
15
  from fly_on_the_wall.cli_speaker_review import speakers_review
15
16
  from fly_on_the_wall.cli_watch import watch_app
16
17
  from fly_on_the_wall.config import load_config
@@ -81,6 +82,7 @@ app.add_typer(refresh_app, name="refresh")
81
82
  app.add_typer(secrets_app, name="secrets")
82
83
  app.add_typer(glossary_app, name="glossary")
83
84
  app.add_typer(watch_app, name="watch")
85
+ app.add_typer(service_app, name="service")
84
86
  app.add_typer(publish_app, name="publish")
85
87
  app.add_typer(costs_app, name="costs")
86
88
  console = Console()
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from fly_on_the_wall.config import config_dir
14
+ from fly_on_the_wall.db import database
15
+ from fly_on_the_wall.watch import list_watch_folders
16
+
17
+ SERVICE_NAME = "fow-watch.service"
18
+ ENV_FILE_NAME = "service.env"
19
+ ENV_FILE_TEMPLATE = """# Optional environment variables for fow-watch.service.
20
+ # Use this if your background service cannot access API keys from the OS keyring.
21
+ # ELEVENLABS_API_KEY=
22
+ # OPENAI_API_KEY=
23
+ """
24
+
25
+ console = Console()
26
+ service_app = typer.Typer(help="Manage the background watch service.", no_args_is_help=True)
27
+
28
+
29
+ @service_app.command("install")
30
+ def service_install(
31
+ enable: Annotated[
32
+ bool,
33
+ typer.Option("--enable", help="Enable the service to start when your user session starts. Default: disabled."),
34
+ ] = False,
35
+ start: Annotated[
36
+ bool,
37
+ typer.Option("--start", help="Start the service after installing it. Default: do not start."),
38
+ ] = False,
39
+ force_start: Annotated[
40
+ bool,
41
+ typer.Option("--force-start", help="Start even when no enabled watch folders are configured."),
42
+ ] = False,
43
+ ) -> None:
44
+ """Install the watch service for the current user."""
45
+ _ensure_supported_platform()
46
+ if start and not force_start:
47
+ _ensure_enabled_watch_folders()
48
+
49
+ unit_path = _unit_file_path()
50
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
51
+ unit_path.write_text(render_unit(_resolve_fow_executable(), _env_file_path()), encoding="utf-8")
52
+ console.print(f"Installed service unit: {unit_path}")
53
+ _ensure_env_file()
54
+ _run_or_exit(_systemctl("daemon-reload"))
55
+
56
+ if enable:
57
+ _run_or_exit(_systemctl("enable", SERVICE_NAME))
58
+ console.print("Service enabled for future user sessions.")
59
+ if start:
60
+ _run_or_exit(_systemctl("start", SERVICE_NAME))
61
+ console.print("Service started.")
62
+
63
+
64
+ @service_app.command("uninstall")
65
+ def service_uninstall() -> None:
66
+ """Remove the watch service for the current user."""
67
+ _ensure_supported_platform()
68
+ unit_path = _unit_file_path()
69
+ if unit_path.exists():
70
+ _run_or_exit(_systemctl("disable", "--now", SERVICE_NAME))
71
+ unit_path.unlink()
72
+ console.print(f"Removed service unit: {unit_path}")
73
+ else:
74
+ console.print(f"Service unit was not installed: {unit_path}")
75
+ _run_or_exit(_systemctl("daemon-reload"))
76
+
77
+
78
+ @service_app.command("start")
79
+ def service_start(
80
+ force: Annotated[
81
+ bool,
82
+ typer.Option("--force", help="Start even when no enabled watch folders are configured."),
83
+ ] = False,
84
+ ) -> None:
85
+ """Start the watch service."""
86
+ _run_watch_service_action("start", force)
87
+
88
+
89
+ @service_app.command("stop")
90
+ def service_stop() -> None:
91
+ """Stop the watch service."""
92
+ _ensure_supported_platform()
93
+ _run_or_exit(_systemctl("stop", SERVICE_NAME))
94
+
95
+
96
+ @service_app.command("restart")
97
+ def service_restart(
98
+ force: Annotated[
99
+ bool,
100
+ typer.Option("--force", help="Restart even when no enabled watch folders are configured."),
101
+ ] = False,
102
+ ) -> None:
103
+ """Restart the watch service."""
104
+ _run_watch_service_action("restart", force)
105
+
106
+
107
+ def _run_watch_service_action(action: str, force: bool) -> None:
108
+ _ensure_supported_platform()
109
+ if not force:
110
+ _ensure_enabled_watch_folders()
111
+ _run_or_exit(_systemctl(action, SERVICE_NAME))
112
+
113
+
114
+ @service_app.command("status")
115
+ def service_status() -> None:
116
+ """Show watch service status."""
117
+ _ensure_supported_platform()
118
+ _run_or_exit(["systemctl", "--user", "--no-pager", "status", SERVICE_NAME])
119
+
120
+
121
+ @service_app.command("logs")
122
+ def service_logs(
123
+ lines: Annotated[int, typer.Option("--lines", "-n", min=1, help="Number of recent log lines to show.")] = 100,
124
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs until interrupted.")] = False,
125
+ ) -> None:
126
+ """Show watch service logs."""
127
+ _ensure_supported_platform()
128
+ command = ["journalctl", "--user", "-u", SERVICE_NAME, "-n", str(lines)]
129
+ if follow:
130
+ command.append("--follow")
131
+ else:
132
+ command.append("--no-pager")
133
+ _run_or_exit(command)
134
+
135
+
136
+ @service_app.command("print-unit")
137
+ def service_print_unit() -> None:
138
+ """Print the generated service unit without installing it."""
139
+ _ensure_supported_platform()
140
+ console.print(render_unit(_resolve_fow_executable(), _env_file_path()), end="", markup=False)
141
+
142
+
143
+ def render_unit(fow_executable: Path, env_file_path: Path) -> str:
144
+ if not fow_executable.is_absolute():
145
+ raise ValueError("fow executable path must be absolute")
146
+ if not env_file_path.is_absolute():
147
+ raise ValueError("service environment file path must be absolute")
148
+ return f"""[Unit]
149
+ Description=Fly on the Wall watch service
150
+ After=default.target
151
+
152
+ [Service]
153
+ Type=simple
154
+ Environment=PYTHONUNBUFFERED=1
155
+ EnvironmentFile=-{_systemd_quote(env_file_path)}
156
+ ExecStart={_systemd_quote(fow_executable)} watch run
157
+ Restart=on-failure
158
+ RestartSec=10
159
+
160
+ [Install]
161
+ WantedBy=default.target
162
+ """
163
+
164
+
165
+ def _ensure_supported_platform() -> None:
166
+ if sys.platform != "linux":
167
+ console.print("Unsupported platform: fow service currently supports Linux systemd user services only.")
168
+ raise typer.Exit(code=1)
169
+
170
+
171
+ def _ensure_enabled_watch_folders() -> None:
172
+ if _has_enabled_watch_folder():
173
+ return
174
+ console.print("No enabled watch folders configured; the watch service would exit immediately.")
175
+ console.print("Add one with: fow watch folders add <path>")
176
+ console.print("To start anyway, pass --force or --force-start.")
177
+ raise typer.Exit(code=1)
178
+
179
+
180
+ def _has_enabled_watch_folder() -> bool:
181
+ with database() as connection:
182
+ return any(folder.enabled for folder in list_watch_folders(connection))
183
+
184
+
185
+ def _resolve_fow_executable() -> Path:
186
+ executable = shutil.which("fow")
187
+ if executable is None:
188
+ console.print("Could not find the fow executable on PATH.")
189
+ raise typer.Exit(code=1)
190
+ return Path(executable).resolve()
191
+
192
+
193
+ def _unit_file_path() -> Path:
194
+ return config_dir().parent / "systemd" / "user" / SERVICE_NAME
195
+
196
+
197
+ def _env_file_path() -> Path:
198
+ return config_dir() / ENV_FILE_NAME
199
+
200
+
201
+ def _ensure_env_file() -> None:
202
+ env_path = _env_file_path()
203
+ if env_path.exists():
204
+ console.print(f"Service environment file already exists: {env_path}")
205
+ return
206
+ env_path.parent.mkdir(parents=True, exist_ok=True)
207
+ env_path.write_text(ENV_FILE_TEMPLATE, encoding="utf-8")
208
+ env_path.chmod(0o600)
209
+ console.print(f"Created service environment placeholder: {env_path}")
210
+
211
+
212
+ def _systemctl(*arguments: str) -> list[str]:
213
+ return ["systemctl", "--user", *arguments]
214
+
215
+
216
+ def _run_or_exit(command: Sequence[str]) -> None:
217
+ try:
218
+ _run_command(command)
219
+ except FileNotFoundError as exc:
220
+ console.print(f"Required command not found: {command[0]}")
221
+ raise typer.Exit(code=1) from exc
222
+ except subprocess.CalledProcessError as exc:
223
+ console.print(f"Command failed with exit code {exc.returncode}: {' '.join(command)}")
224
+ raise typer.Exit(code=exc.returncode) from exc
225
+
226
+
227
+ def _run_command(command: Sequence[str]) -> None:
228
+ subprocess.run(command, check=True)
229
+
230
+
231
+ def _systemd_quote(path: Path) -> str:
232
+ text = str(path)
233
+ if not any(character.isspace() for character in text) and '"' not in text:
234
+ return text
235
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
@@ -31,7 +31,10 @@ def create_glossary_term(connection: Connection, term: str, description: str | N
31
31
  """,
32
32
  (term_id, normalized, normalized_description),
33
33
  )
34
- return get_glossary_term(connection, normalized) # type: ignore[return-value]
34
+ created = get_glossary_term(connection, normalized)
35
+ if created is None:
36
+ raise RuntimeError(f"Created glossary term was not found: {normalized}")
37
+ return created
35
38
 
36
39
 
37
40
  def get_glossary_term(connection: Connection, term_or_id: str) -> GlossaryTerm | None:
@@ -80,7 +83,10 @@ def update_glossary_term(
80
83
  """,
81
84
  (updated_term, updated_description, int(updated_enabled), existing.id),
82
85
  )
83
- return get_glossary_term(connection, existing.id) # type: ignore[return-value]
86
+ updated = get_glossary_term(connection, existing.id)
87
+ if updated is None:
88
+ raise RuntimeError(f"Updated glossary term was not found: {existing.id}")
89
+ return updated
84
90
 
85
91
 
86
92
  def remove_glossary_term(connection: Connection, term_or_id: str) -> bool:
File without changes
File without changes