mac-upkeep 2.1.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.
mac_upkeep/__init__.py ADDED
File without changes
mac_upkeep/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """mac-upkeep CLI — automated macOS maintenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import importlib.resources
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ from importlib.metadata import version as pkg_version
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+
19
+ from mac_upkeep.config import (
20
+ DEFAULT_CONFIG_DIR,
21
+ DEFAULT_CONFIG_PATH,
22
+ Config,
23
+ _build_variables,
24
+ _load_defaults,
25
+ get_brew_prefix,
26
+ resolve_variables,
27
+ )
28
+ from mac_upkeep.notify import detect_terminal_bundle_id, format_summary, notify
29
+ from mac_upkeep.output import Output
30
+ from mac_upkeep.tasks import TASKS, _load_state, run_all_tasks
31
+
32
+ app = typer.Typer(
33
+ help="Automated macOS mac-upkeep CLI.\n\n"
34
+ "Runs 11 tasks: brew update/upgrade, gcloud, pnpm, uv, fisher, "
35
+ "mo clean/optimize/purge, brew cleanup, brew bundle cleanup.\n\n"
36
+ "Install: brew install calvindotsg/tap/mac-upkeep\n\n"
37
+ "Schedule: brew services start mac-upkeep (Monday 12 PM weekly)\n\n"
38
+ "Config: ~/.config/mac-upkeep/config.toml",
39
+ no_args_is_help=True,
40
+ )
41
+
42
+
43
+ def _complete_force(ctx: typer.Context, incomplete: str) -> list[tuple[str, str]]:
44
+ """Shell completion for --force task names."""
45
+ try:
46
+ config = Config.load()
47
+ task_names = {
48
+ name: config.task_defs[name].description
49
+ for name in config.run_order
50
+ if name in config.task_defs
51
+ }
52
+ except Exception:
53
+ task_names = TASKS # fallback to import-time defaults
54
+ already = set(ctx.params.get("force") or [])
55
+ completions: list[tuple[str, str]] = []
56
+ if "all".startswith(incomplete) and "all" not in already:
57
+ completions.append(("all", "Force all tasks"))
58
+ for name, desc in task_names.items():
59
+ if name.startswith(incomplete) and name not in already:
60
+ completions.append((name, desc))
61
+ return completions
62
+
63
+
64
+ def _setup_logging(debug: bool = False) -> None:
65
+ level = logging.DEBUG if debug else logging.INFO
66
+ logging.basicConfig(
67
+ format="[mac-upkeep] %(asctime)s %(message)s",
68
+ datefmt="%Y-%m-%d %H:%M:%S",
69
+ level=level,
70
+ )
71
+
72
+
73
+ def _handle_signal(signum: int, _frame: object) -> None:
74
+ logging.getLogger("mac_upkeep").warning("Interrupted (signal %d)", signum)
75
+ sys.exit(130)
76
+
77
+
78
+ signal.signal(signal.SIGINT, _handle_signal)
79
+ signal.signal(signal.SIGTERM, _handle_signal)
80
+
81
+
82
+ def _version_callback(value: bool) -> None:
83
+ if value:
84
+ try:
85
+ v = pkg_version("mac-upkeep")
86
+ except Exception:
87
+ v = "unknown"
88
+ typer.echo(f"mac-upkeep {v}")
89
+ raise typer.Exit()
90
+
91
+
92
+ @app.callback()
93
+ def main(
94
+ _version: Annotated[
95
+ bool | None,
96
+ typer.Option(
97
+ "--version", "-v", callback=_version_callback, is_eager=True, help="Show version."
98
+ ),
99
+ ] = None,
100
+ ) -> None:
101
+ """Automated macOS mac-upkeep CLI."""
102
+ if sys.platform != "darwin":
103
+ typer.echo("mac-upkeep requires macOS.", err=True)
104
+ raise typer.Exit(code=1)
105
+
106
+
107
+ @app.command()
108
+ def run(
109
+ dry_run: Annotated[
110
+ bool, typer.Option("--dry-run", "-n", help="Preview tasks without executing.")
111
+ ] = False,
112
+ debug: Annotated[bool, typer.Option("--debug", help="Show detailed debug output.")] = False,
113
+ force: Annotated[
114
+ list[str] | None,
115
+ typer.Option(
116
+ "--force",
117
+ "-f",
118
+ help="Run only specified task(s), ignoring schedule. Repeat for multiple.",
119
+ autocompletion=_complete_force,
120
+ ),
121
+ ] = None,
122
+ ) -> None:
123
+ """Run all mac-upkeep tasks.
124
+
125
+ Tasks are auto-detected: missing tools are skipped with a log message.
126
+ Disable specific tasks via config file or MAC_UPKEEP_<TASK>=false environment variables.
127
+
128
+ Task order: brew_update, brew_upgrade, gcloud, pnpm, uv, fisher,
129
+ mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle.
130
+ brew_cleanup runs after mo_clean (which runs brew autoremove).
131
+ brew_bundle runs last (homebrew/brew#21350).
132
+
133
+ Exit codes: 0 = completed (some tasks may be skipped), 130 = interrupted.
134
+ """
135
+ _setup_logging(debug)
136
+ config = Config.load()
137
+ output = Output(debug=debug)
138
+
139
+ # Validate and convert --force option
140
+ force_set: set[str] | None = None
141
+ if force is not None:
142
+ valid_names = set(config.run_order)
143
+ if "all" in force:
144
+ force_set = valid_names
145
+ else:
146
+ invalid = [t for t in force if t not in valid_names]
147
+ if invalid:
148
+ typer.echo(f"Unknown task(s): {', '.join(invalid)}", err=True)
149
+ typer.echo(f"Valid tasks: {', '.join(config.run_order)}", err=True)
150
+ raise typer.Exit(1)
151
+ force_set = set(force)
152
+
153
+ output.header(dry_run=dry_run, task_names=config.run_order)
154
+ results = run_all_tasks(config=config, output=output, dry_run=dry_run, force_tasks=force_set)
155
+ output.summary(results)
156
+
157
+ if config.notify and not dry_run:
158
+ title, message, subtitle = format_summary(results)
159
+ brew_prefix = get_brew_prefix()
160
+ log_url = f"file://{brew_prefix}/var/log/mac-upkeep.log"
161
+ bundle_id = detect_terminal_bundle_id()
162
+ notify(
163
+ title,
164
+ message,
165
+ subtitle=subtitle,
166
+ sound=config.notify_sound,
167
+ activate_bundle_id=bundle_id,
168
+ open_url=log_url,
169
+ )
170
+
171
+
172
+ @app.command()
173
+ def tasks() -> None:
174
+ """List all tasks with frequency, status, and last run time."""
175
+ config = Config.load()
176
+ state = _load_state()
177
+
178
+ task_list = [
179
+ (name, config.task_defs[name]) for name in config.run_order if name in config.task_defs
180
+ ]
181
+
182
+ if sys.stdout.isatty():
183
+ from rich.console import Console
184
+ from rich.table import Table
185
+
186
+ table = Table(title="Tasks", title_style="bold", box=None, padding=(0, 2))
187
+ table.add_column("Task", min_width=14)
188
+ table.add_column("Description", min_width=20)
189
+ table.add_column("Frequency", min_width=8)
190
+ table.add_column("Enabled", min_width=7)
191
+ table.add_column("Last Run", min_width=10)
192
+
193
+ for name, td in task_list:
194
+ enabled = "[green]yes[/green]" if td.enabled else "[dim]no[/dim]"
195
+ last_run = state.get(name, "never")
196
+ table.add_row(name, td.description, td.frequency, enabled, last_run)
197
+
198
+ Console(highlight=False).print(table)
199
+ else:
200
+ for name, td in task_list:
201
+ enabled = "yes" if td.enabled else "no"
202
+ last_run = state.get(name, "never")
203
+ typer.echo(f"{name}\t{td.description}\t{td.frequency}\t{enabled}\t{last_run}")
204
+
205
+
206
+ @app.command()
207
+ def init(
208
+ force: Annotated[bool, typer.Option("--force", help="Overwrite existing config.")] = False,
209
+ ) -> None:
210
+ """Generate a starter config based on detected tools.
211
+
212
+ Probes your system to discover installed tools and writes a commented
213
+ config to ~/.config/mac-upkeep/config.toml. Only detected tasks are
214
+ listed. Built-in defaults apply automatically — uncomment to override.
215
+ """
216
+ config_path = DEFAULT_CONFIG_PATH
217
+
218
+ if config_path.is_file() and not force:
219
+ typer.echo(f"Config already exists: {config_path}")
220
+ typer.echo("Use --force to overwrite.")
221
+ raise typer.Exit(1)
222
+
223
+ defaults = _load_defaults()
224
+ variables = _build_variables("")
225
+ tasks_data = defaults.get("tasks", {})
226
+ run_order = defaults.get("run", {}).get("order", list(tasks_data.keys()))
227
+
228
+ detected: list[tuple[str, dict]] = []
229
+ not_detected: list[tuple[str, dict]] = []
230
+
231
+ for task_name in run_order:
232
+ data = tasks_data.get(task_name, {})
233
+ detect_raw = data.get("detect", "")
234
+ if detect_raw:
235
+ try:
236
+ detect_bin = resolve_variables(detect_raw, variables)
237
+ except ValueError:
238
+ detect_bin = detect_raw
239
+ else:
240
+ detect_bin = ""
241
+
242
+ if detect_bin and shutil.which(detect_bin):
243
+ detected.append((task_name, data))
244
+ else:
245
+ not_detected.append((task_name, data))
246
+
247
+ config_text = _generate_init_config(detected, not_detected)
248
+
249
+ DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
250
+ config_path.write_text(config_text)
251
+ typer.echo(f"Created {config_path}")
252
+ typer.echo(f" {len(detected)} tasks detected, {len(not_detected)} not found")
253
+ typer.echo(" Edit the file to customize. Run 'mac-upkeep tasks' to see status.")
254
+
255
+
256
+ def _generate_init_config(
257
+ detected: list[tuple[str, dict]],
258
+ not_detected: list[tuple[str, dict]],
259
+ ) -> str:
260
+ """Generate an all-comments config TOML string from detection results."""
261
+ from datetime import date
262
+
263
+ lines: list[str] = []
264
+ total = len(detected) + len(not_detected)
265
+
266
+ lines.append("# mac-upkeep configuration")
267
+ lines.append(
268
+ f"# Generated {date.today()} — {len(detected)} of {total} tasks detected on this system."
269
+ )
270
+ lines.append("#")
271
+ lines.append("# Built-in defaults apply automatically. Only uncomment lines to change.")
272
+ lines.append("# Run 'mac-upkeep tasks' to see task status and last run times.")
273
+ lines.append("# Run 'mac-upkeep show-config --default' to see all defaults.")
274
+ lines.append("")
275
+
276
+ if detected:
277
+ lines.append("# ── Detected tasks (enabled by default) ─────────────────────────")
278
+ for task_name, data in detected:
279
+ desc = data.get("description", "")
280
+ freq = data.get("frequency", "weekly")
281
+ lines.append(f"# {task_name:<16} {desc:<44} {freq}")
282
+ lines.append("")
283
+
284
+ if not_detected:
285
+ lines.append("# ── Not detected (install to enable) ───────────────────────────")
286
+ for task_name, data in not_detected:
287
+ desc = data.get("description", "")
288
+ detect_bin = data.get("detect", "?").split("/")[-1]
289
+ lines.append(f"# {task_name:<16} {desc:<44} ({detect_bin} not found)")
290
+ lines.append("")
291
+
292
+ lines.append("# ── Customize " + "─" * 54)
293
+ lines.append("# [tasks.brew_update]")
294
+ lines.append('# frequency = "monthly"')
295
+ lines.append("#")
296
+ lines.append("# [tasks.fisher]")
297
+ lines.append("# enabled = false")
298
+ lines.append("#")
299
+ lines.append("# [tasks.docker_prune]")
300
+ lines.append('# description = "Prune Docker system"')
301
+ lines.append('# command = "docker system prune -f"')
302
+ lines.append('# detect = "docker"')
303
+ lines.append('# frequency = "monthly"')
304
+ lines.append("#")
305
+ if detected:
306
+ order_str = str([t for t, _ in detected]).replace("'", '"')
307
+ lines.append("# [run]")
308
+ lines.append(f"# order = {order_str}")
309
+ lines.append("")
310
+
311
+ return "\n".join(lines)
312
+
313
+
314
+ @app.command(name="show-config")
315
+ def show_config(
316
+ default: Annotated[
317
+ bool, typer.Option("--default", help="Show bundled defaults (all tasks).")
318
+ ] = False,
319
+ ) -> None:
320
+ """Show configuration as TOML.
321
+
322
+ With --default: outputs the bundled defaults.toml (all tasks and options).
323
+ Without --default: outputs the user's config overrides, or a setup message.
324
+ """
325
+ if default:
326
+ text = (
327
+ importlib.resources.files("mac_upkeep")
328
+ .joinpath("defaults.toml")
329
+ .read_text(encoding="utf-8")
330
+ )
331
+ typer.echo(text.rstrip())
332
+ else:
333
+ if DEFAULT_CONFIG_PATH.is_file():
334
+ typer.echo(DEFAULT_CONFIG_PATH.read_text().rstrip())
335
+ else:
336
+ typer.echo(f"No config file found at {DEFAULT_CONFIG_PATH}")
337
+ typer.echo(
338
+ "Run 'mac-upkeep init' to generate one, or "
339
+ "'mac-upkeep show-config --default' to see all defaults."
340
+ )
341
+
342
+
343
+ @app.command(name="notify-test")
344
+ def notify_test() -> None:
345
+ """Send a test notification to verify macOS permissions.
346
+
347
+ Useful after initial setup to confirm notifications are allowed in
348
+ System Settings > Notifications.
349
+ """
350
+ config = Config.load()
351
+ brew_prefix = get_brew_prefix()
352
+ bundle_id = detect_terminal_bundle_id()
353
+ log_url = f"file://{brew_prefix}/var/log/mac-upkeep.log"
354
+ ok = notify(
355
+ "mac-upkeep",
356
+ "Test notification",
357
+ sound=config.notify_sound,
358
+ activate_bundle_id=bundle_id,
359
+ open_url=log_url,
360
+ )
361
+ if ok:
362
+ typer.echo("Notification sent. Check your notification center.")
363
+ else:
364
+ typer.echo("Notification failed. Check System Settings > Notifications.")
365
+ raise typer.Exit(1)
366
+
367
+
368
+ @app.command()
369
+ def setup() -> None:
370
+ """Print sudoers rules for this machine.
371
+
372
+ Generates machine-specific rules using your username and Homebrew prefix.
373
+ Pipe to sudoers::
374
+
375
+ mac-upkeep setup | sudo tee /etc/sudoers.d/mac-upkeep
376
+ sudo chmod 0440 /etc/sudoers.d/mac-upkeep
377
+
378
+ The env_keep line preserves HOME so mole operates on your home directory,
379
+ not /var/root (which is the default when running via sudo).
380
+ """
381
+ user = getpass.getuser()
382
+ brew_prefix = get_brew_prefix()
383
+ mo_bin = f"{brew_prefix}/bin/mo"
384
+
385
+ typer.echo(f"# Sudoers rules for mac-upkeep CLI ({user}@{brew_prefix})")
386
+ typer.echo(
387
+ "# Install: mac-upkeep setup | sudo tee /etc/sudoers.d/mac-upkeep"
388
+ " && sudo chmod 0440 /etc/sudoers.d/mac-upkeep"
389
+ )
390
+ typer.echo()
391
+ typer.echo(f'Defaults!{mo_bin} env_keep += "HOME"')
392
+ typer.echo(f"{user} ALL = (root) NOPASSWD: {mo_bin} clean")
393
+ typer.echo(f"{user} ALL = (root) NOPASSWD: {mo_bin} optimize")
394
+ typer.echo()
395
+ typer.echo("# Log rotation (install separately):")
396
+ log_path = f"{brew_prefix}/var/log/mac-upkeep.log"
397
+ typer.echo(f"# echo '{log_path} {user}:admin 644 12 * $M1D0 GN'")
398
+ typer.echo("# | sudo tee /etc/newsyslog.d/mac-upkeep.conf")
399
+
400
+
401
+ @app.command()
402
+ def status() -> None:
403
+ """Show brew service status for mac-upkeep."""
404
+ try:
405
+ result = subprocess.run(
406
+ ["brew", "services", "info", "mac-upkeep"],
407
+ capture_output=True,
408
+ text=True,
409
+ )
410
+ typer.echo(result.stdout.strip())
411
+ except FileNotFoundError:
412
+ typer.echo("brew not found. Install Homebrew first.")
413
+ raise typer.Exit(1)
414
+
415
+
416
+ @app.command()
417
+ def logs(
418
+ follow: Annotated[bool, typer.Option("-f", "--follow", help="Follow log output.")] = False,
419
+ lines: Annotated[int, typer.Argument(help="Number of lines to show.")] = 20,
420
+ ) -> None:
421
+ """View mac-upkeep log output.
422
+
423
+ Logs are written by brew services to $(brew --prefix)/var/log/mac-upkeep.log.
424
+ """
425
+ brew_prefix = get_brew_prefix()
426
+ log_file = Path(brew_prefix) / "var" / "log" / "mac-upkeep.log"
427
+
428
+ if not log_file.is_file():
429
+ typer.echo(f"No log file found at {log_file}")
430
+ raise typer.Exit(1)
431
+
432
+ cmd = ["tail"]
433
+ if follow:
434
+ cmd.append("-f")
435
+ else:
436
+ cmd.extend(["-n", str(lines)])
437
+ cmd.append(str(log_file))
438
+
439
+ os.execvp("tail", cmd)
mac_upkeep/config.py ADDED
@@ -0,0 +1,264 @@
1
+ """Configuration loading from TOML files and environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import tomllib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ _xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
16
+ DEFAULT_CONFIG_DIR = Path(_xdg) / "mac-upkeep"
17
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
18
+
19
+
20
+ @dataclass
21
+ class TaskDef:
22
+ """A task definition loaded from TOML."""
23
+
24
+ name: str
25
+ description: str
26
+ command: str
27
+ detect: str = ""
28
+ frequency: str = "weekly"
29
+ enabled: bool = True
30
+ sudo: bool = False
31
+ shell: str = ""
32
+ require_file: str = ""
33
+ timeout: int = 300
34
+
35
+
36
+ def get_brew_prefix() -> str:
37
+ """Detect Homebrew prefix (portable: Apple Silicon /opt/homebrew, Intel /usr/local)."""
38
+ brew = shutil.which("brew")
39
+ if brew:
40
+ try:
41
+ result = subprocess.run([brew, "--prefix"], capture_output=True, text=True, timeout=5)
42
+ return result.stdout.strip()
43
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
44
+ pass
45
+ return "/opt/homebrew" if os.uname().machine == "arm64" else "/usr/local"
46
+
47
+
48
+ def resolve_variables(value: str, variables: dict[str, str]) -> str:
49
+ """Replace ${VAR} placeholders with values. Raises ValueError on unknown vars."""
50
+ for var_name, var_value in variables.items():
51
+ value = value.replace(f"${{{var_name}}}", var_value)
52
+ unresolved = re.findall(r"\$\{(\w+)\}", value)
53
+ if unresolved:
54
+ msg = ", ".join(f"${{{v}}}" for v in unresolved)
55
+ raise ValueError(f"Unknown variable(s): {msg}")
56
+ return value
57
+
58
+
59
+ def _build_variables(brewfile: str) -> dict[str, str]:
60
+ """Build the variable dict for template resolution."""
61
+ return {
62
+ "BREW_PREFIX": get_brew_prefix(),
63
+ "BREWFILE": brewfile or "",
64
+ "HOME": str(Path.home()),
65
+ }
66
+
67
+
68
+ def _load_defaults() -> dict:
69
+ """Load bundled defaults.toml via importlib.resources."""
70
+ text = (
71
+ importlib.resources.files("mac_upkeep")
72
+ .joinpath("defaults.toml")
73
+ .read_text(encoding="utf-8")
74
+ )
75
+ return tomllib.loads(text)
76
+
77
+
78
+ def load_default_task_names() -> tuple[dict[str, str], list[str]]:
79
+ """Load task names and order from bundled defaults.toml.
80
+
81
+ Returns (task_name_to_description, run_order). Used at import time
82
+ by tasks.py for shell completion.
83
+ """
84
+ data = _load_defaults()
85
+ tasks = {name: defn.get("description", "") for name, defn in data.get("tasks", {}).items()}
86
+ order = data.get("run", {}).get("order", list(tasks.keys()))
87
+ return tasks, order
88
+
89
+
90
+ def load_task_defs(
91
+ user_data: dict | None,
92
+ variables: dict[str, str],
93
+ ) -> tuple[dict[str, TaskDef], list[str]]:
94
+ """Load task definitions from defaults.toml, merge with user config.
95
+
96
+ Args:
97
+ user_data: Pre-parsed user TOML dict (or None if no user config).
98
+ variables: Variable dict for ${VAR} resolution.
99
+
100
+ Returns:
101
+ (task_defs, run_order)
102
+ """
103
+ defaults = _load_defaults()
104
+
105
+ # Parse default tasks
106
+ task_defs: dict[str, TaskDef] = {}
107
+ for name, data in defaults.get("tasks", {}).items():
108
+ task_defs[name] = _parse_task_def(name, data)
109
+
110
+ # Default run order
111
+ run_order = defaults.get("run", {}).get("order", list(task_defs.keys()))
112
+
113
+ # Merge user overrides
114
+ if user_data:
115
+ for name, user_fields in user_data.get("tasks", {}).items():
116
+ if name in task_defs:
117
+ # Field-level override: only specified fields change
118
+ td = task_defs[name]
119
+ for field_name, value in user_fields.items():
120
+ if hasattr(td, field_name):
121
+ setattr(td, field_name, value)
122
+ else:
123
+ # New custom task
124
+ task_defs[name] = _parse_task_def(name, user_fields)
125
+
126
+ # User run order replaces default entirely
127
+ if "run" in user_data and "order" in user_data["run"]:
128
+ run_order = user_data["run"]["order"]
129
+ else:
130
+ # Auto-append custom tasks to default order
131
+ for name in user_data.get("tasks", {}):
132
+ if name not in run_order:
133
+ run_order.append(name)
134
+
135
+ # Validate task definitions
136
+ for name, td in task_defs.items():
137
+ if not td.command:
138
+ raise ValueError(f"Task '{name}' has no command")
139
+ if td.frequency not in ("weekly", "monthly"):
140
+ raise ValueError(
141
+ f"Task '{name}': frequency must be 'weekly' or 'monthly', got '{td.frequency}'"
142
+ )
143
+ for entry in run_order:
144
+ if entry not in task_defs:
145
+ raise ValueError(f"run.order references unknown task '{entry}'")
146
+
147
+ # Env var overrides (MAC_UPKEEP_<TASK>=false, MAC_UPKEEP_<TASK>_FREQUENCY=monthly)
148
+ for task_name, td in task_defs.items():
149
+ env_key = f"MAC_UPKEEP_{task_name.upper()}"
150
+ env_val = os.environ.get(env_key)
151
+ if env_val is not None:
152
+ td.enabled = env_val.lower() not in ("false", "0", "no")
153
+
154
+ freq_key = f"MAC_UPKEEP_{task_name.upper()}_FREQUENCY"
155
+ freq_val = os.environ.get(freq_key)
156
+ if freq_val is not None:
157
+ td.frequency = freq_val.lower()
158
+
159
+ # Resolve variables in command, detect, require_file
160
+ for td in task_defs.values():
161
+ td.command = resolve_variables(td.command, variables)
162
+ if td.detect:
163
+ td.detect = resolve_variables(td.detect, variables)
164
+ if td.require_file:
165
+ td.require_file = resolve_variables(td.require_file, variables)
166
+
167
+ # Auto-infer detect from command for tasks that don't set it
168
+ for td in task_defs.values():
169
+ if not td.detect and td.command:
170
+ td.detect = shlex.split(td.command)[0]
171
+
172
+ return task_defs, run_order
173
+
174
+
175
+ def _parse_task_def(name: str, data: dict) -> TaskDef:
176
+ """Parse a TOML task table into a TaskDef."""
177
+ return TaskDef(
178
+ name=name,
179
+ description=data.get("description", ""),
180
+ command=data.get("command", ""),
181
+ detect=data.get("detect", ""),
182
+ frequency=data.get("frequency", "weekly"),
183
+ enabled=data.get("enabled", True),
184
+ sudo=data.get("sudo", False),
185
+ shell=data.get("shell", ""),
186
+ require_file=data.get("require_file", ""),
187
+ timeout=data.get("timeout", 300),
188
+ )
189
+
190
+
191
+ @dataclass
192
+ class Config:
193
+ """mac-upkeep configuration loaded from TOML + environment overrides."""
194
+
195
+ task_defs: dict[str, TaskDef] = field(default_factory=dict)
196
+ run_order: list[str] = field(default_factory=list)
197
+ brewfile: str | None = None
198
+ notify: bool = True
199
+ notify_sound: str = "Submarine"
200
+
201
+ @classmethod
202
+ def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> Config:
203
+ """Load config from TOML file, then apply environment variable overrides."""
204
+ config = cls()
205
+
206
+ # Read user TOML once (used for both settings and task overrides)
207
+ user_data: dict | None = None
208
+ if path.is_file():
209
+ with open(path, "rb") as f:
210
+ user_data = tomllib.load(f)
211
+
212
+ # Extract notifications from user config
213
+ if user_data and "notifications" in user_data:
214
+ notif = user_data["notifications"]
215
+ if "enabled" in notif:
216
+ config.notify = bool(notif["enabled"])
217
+ if "sound" in notif:
218
+ config.notify_sound = str(notif["sound"])
219
+
220
+ # Extract brewfile from user config
221
+ if user_data and "paths" in user_data and "brewfile" in user_data["paths"]:
222
+ config.brewfile = user_data["paths"]["brewfile"]
223
+
224
+ # Notification env override
225
+ env_notify = os.environ.get("MAC_UPKEEP_NOTIFY")
226
+ if env_notify is not None:
227
+ config.notify = env_notify.lower() not in ("false", "0", "no")
228
+
229
+ # Brewfile path: env var → config file → HOMEBREW_BUNDLE_FILE → auto-discover
230
+ if os.environ.get("MAC_UPKEEP_BREWFILE"):
231
+ config.brewfile = os.environ["MAC_UPKEEP_BREWFILE"]
232
+ if not config.brewfile:
233
+ config.brewfile = os.environ.get("HOMEBREW_BUNDLE_FILE")
234
+ if not config.brewfile:
235
+ config.brewfile = _discover_brewfile()
236
+
237
+ # Build variables and load task definitions
238
+ variables = _build_variables(config.brewfile or "")
239
+ config.task_defs, config.run_order = load_task_defs(user_data, variables)
240
+
241
+ return config
242
+
243
+ def is_enabled(self, task: str) -> bool:
244
+ """Check if a task is enabled in config."""
245
+ td = self.task_defs.get(task)
246
+ return td.enabled if td else True
247
+
248
+ def get_frequency(self, task: str) -> str:
249
+ """Get the frequency for a task ('weekly' or 'monthly')."""
250
+ td = self.task_defs.get(task)
251
+ return td.frequency if td else "weekly"
252
+
253
+
254
+ def _discover_brewfile() -> str | None:
255
+ """Auto-discover Brewfile from common locations."""
256
+ candidates = [
257
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Brewfile",
258
+ Path.home() / ".Brewfile",
259
+ Path("Brewfile"),
260
+ ]
261
+ for candidate in candidates:
262
+ if candidate.is_file():
263
+ return str(candidate)
264
+ return None