mac-upkeep 2.2.0__tar.gz → 2.3.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 (32) hide show
  1. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/.github/workflows/test.yml +5 -0
  2. mac_upkeep-2.3.0/.release-please-manifest.json +3 -0
  3. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/CHANGELOG.md +16 -0
  4. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/CLAUDE.md +5 -2
  5. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/PKG-INFO +5 -6
  6. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/README.md +3 -3
  7. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/docs/reusable-patterns.md +1 -1
  8. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/llms.txt +1 -1
  9. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/pyproject.toml +4 -4
  10. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/cli.py +172 -16
  11. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/tasks.py +39 -1
  12. mac_upkeep-2.3.0/tests/__init__.py +0 -0
  13. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/tests/test_cli.py +58 -0
  14. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/tests/test_tasks.py +68 -1
  15. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/uv.lock +1 -1
  16. mac_upkeep-2.2.0/.release-please-manifest.json +0 -3
  17. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/.github/workflows/release.yml +0 -0
  18. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/.gitignore +0 -0
  19. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/CONTRIBUTING.md +0 -0
  20. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/LICENSE +0 -0
  21. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/demo/demo.gif +0 -0
  22. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/demo/record.sh +0 -0
  23. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/release-please-config.json +0 -0
  24. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/__init__.py +0 -0
  25. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/config.py +0 -0
  26. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/defaults.toml +0 -0
  27. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/notify.py +0 -0
  28. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/src/mac_upkeep/output.py +0 -0
  29. /mac_upkeep-2.2.0/tests/__init__.py → /mac_upkeep-2.3.0/src/mac_upkeep/py.typed +0 -0
  30. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/tests/test_config.py +0 -0
  31. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/tests/test_notify.py +0 -0
  32. {mac_upkeep-2.2.0 → mac_upkeep-2.3.0}/tests/test_output.py +0 -0
@@ -7,9 +7,14 @@ on:
7
7
  jobs:
8
8
  test:
9
9
  runs-on: macos-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.11", "3.12", "3.13"]
10
13
  steps:
11
14
  - uses: actions/checkout@v4
12
15
  - uses: astral-sh/setup-uv@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
13
18
  - run: uv lock --check
14
19
  - run: uv sync
15
20
  - run: uv run ruff check src/ tests/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.3.0"
3
+ }
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.1...v2.3.0) (2026-04-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * add next scheduled run visibility to tasks and run ([6317ce1](https://github.com/calvindotsg/mac-upkeep/commit/6317ce18c041a8f29a8e8534a061bfd3aec24343))
9
+ * add next scheduled run visibility to tasks and run ([34dbe93](https://github.com/calvindotsg/mac-upkeep/commit/34dbe93fab3781d7ce669c852b6c06eb0ab8d5ca))
10
+ * redesign status command as scheduling dashboard ([#34](https://github.com/calvindotsg/mac-upkeep/issues/34)) ([86ca27c](https://github.com/calvindotsg/mac-upkeep/commit/86ca27cb7f7b1fd8878996552246061ebdd6d598))
11
+
12
+ ## [2.2.1](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.0...v2.2.1) (2026-04-09)
13
+
14
+
15
+ ### Miscellaneous
16
+
17
+ * improve PyPI metadata and CI coverage ([#30](https://github.com/calvindotsg/mac-upkeep/issues/30)) ([7428b61](https://github.com/calvindotsg/mac-upkeep/commit/7428b610d1523d0e4b22e397185ad68e01240940))
18
+
3
19
  ## [2.2.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.1.2...v2.2.0) (2026-04-09)
4
20
 
5
21
 
@@ -23,8 +23,8 @@ defaults.toml → bundled task definitions (11 tasks), loaded via importlib.reso
23
23
  config.py → TaskDef dataclass, load_task_defs(), resolve_variables(), get_brew_prefix(),
24
24
  Config.load() (3-layer merge: defaults.toml → user config → env vars)
25
25
  tasks.py → _build_cmd(), run_task(), _run(), run_all_tasks() data-driven loop,
26
- frequency scheduling, ANSI stripping
27
- cli.py → Typer app: run, tasks, init, show-config, setup, status, logs, notify-test
26
+ frequency scheduling, format_last_run(), format_next_run(), ANSI stripping
27
+ cli.py → Typer app: run, tasks, init, show-config, setup, status (dashboard), logs, notify-test
28
28
  output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging (non-interactive)
29
29
  notify.py → macOS notifications via terminal-notifier (preferred) / osascript (fallback)
30
30
  ```
@@ -64,6 +64,8 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
64
64
  - Thresholds are 6 days for weekly and 27 days for monthly (not 7/30 — buffer for launchd schedule drift after sleep/reboot). State tracked in `~/.local/state/mac-upkeep/last-run.json`.
65
65
  - **Safety net**: prevents redundant runs from RunAtLoad boot triggers, launchd coalescing, and manual `mac-upkeep run`. `run_at_load true` is intentional — `StartCalendarInterval` does NOT coalesce from power-off (only sleep), so RunAtLoad is essential for laptops that reboot frequently.
66
66
  - Timestamps only update on successful non-dry-run execution. Corrupt/missing state file silently triggers re-run.
67
+ - **`FREQUENCY_THRESHOLDS` is dual-purpose**: used for gating in `_should_run()` and for display in `format_next_run()`. `format_next_run()` accepts an optional `state` dict parameter to avoid redundant `_load_state()` calls — the `tasks` command pre-loads state once; `_run()` skip path omits it (one-off read is fine).
68
+ - **Status column priority in `tasks` command**: `disabled → not found → ready` mirrors the check order in `run_task()` (disabled check then detection check) but is computed independently in `cli.py` using `td.enabled` and `shutil.which(td.detect)`. `td.detect` is already variable-resolved and auto-inferred by `Config.load()`, so `shutil.which(td.detect)` works directly — no raw TOML variable resolution needed.
67
69
 
68
70
  ### Output and notifications
69
71
 
@@ -73,6 +75,7 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
73
75
  - **terminal-notifier preferred**: `shutil.which("terminal-notifier")` tries the richer tool first. Fallback to osascript loses `-group` (dedup), `-activate` (focus terminal), `-open` (click action).
74
76
  - **Bundle ID detection chain**: `CMUX_BUNDLE_ID` env var → Ghostty.app plist via `defaults read` → `com.apple.Terminal` fallback.
75
77
  - **Rich is a transitive dependency**: `typer>=0.12` requires `rich>=12.3.0`. Using Rich adds zero new runtime dependencies.
78
+ - **`status` dashboard graceful degradation**: if `_get_service_info()` returns None (brew not installed, service not registered, or JSON parse failure), the service header is skipped and only the task scheduling summary is shown. Reuses `format_last_run()` and `format_next_run()` from tasks.py. Test by patching `mac_upkeep.cli._get_service_info` directly rather than mocking subprocess.run (avoids interfering with Config.load() → get_brew_prefix() subprocess call).
76
79
 
77
80
  ### sudo + HOME
78
81
 
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mac-upkeep
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Automated macOS maintenance CLI
5
5
  Project-URL: Homepage, https://github.com/calvindotsg/mac-upkeep
6
6
  Project-URL: Repository, https://github.com/calvindotsg/mac-upkeep
7
7
  Project-URL: Issues, https://github.com/calvindotsg/mac-upkeep/issues
8
8
  Project-URL: Changelog, https://github.com/calvindotsg/mac-upkeep/blob/main/CHANGELOG.md
9
- Project-URL: Documentation, https://github.com/calvindotsg/mac-upkeep#readme
10
9
  Author: Calvin
11
10
  License-Expression: MIT
12
11
  License-File: LICENSE
@@ -15,13 +14,13 @@ Classifier: Development Status :: 4 - Beta
15
14
  Classifier: Environment :: Console
16
15
  Classifier: Environment :: MacOS X
17
16
  Classifier: Intended Audience :: Developers
18
- Classifier: License :: OSI Approved :: MIT License
19
17
  Classifier: Operating System :: MacOS
20
18
  Classifier: Programming Language :: Python :: 3
21
19
  Classifier: Programming Language :: Python :: 3.11
22
20
  Classifier: Programming Language :: Python :: 3.12
23
21
  Classifier: Programming Language :: Python :: 3.13
24
22
  Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
25
24
  Requires-Python: >=3.11
26
25
  Requires-Dist: typer>=0.12
27
26
  Description-Content-Type: text/markdown
@@ -71,7 +70,7 @@ uvx mac-upkeep run # one-off without installing
71
70
  Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
72
71
 
73
72
  ```bash
74
- mac-upkeep tasks # See all tasks with frequency and last-run status
73
+ mac-upkeep tasks # See all tasks with status, frequency, and next run
75
74
  ```
76
75
 
77
76
  ## Usage
@@ -82,12 +81,12 @@ mac-upkeep run --dry-run # Preview without executing
82
81
  mac-upkeep run --force brew_update # Run only brew_update
83
82
  mac-upkeep run --force all # Run all, ignoring schedule
84
83
  mac-upkeep run --debug # Verbose output
85
- mac-upkeep tasks # List tasks with status
84
+ mac-upkeep tasks # List tasks with status and next run
86
85
  mac-upkeep init # Generate config (detects your tools)
87
86
  mac-upkeep show-config --default # Show all available task options
88
87
  mac-upkeep show-config # Show your config overrides
89
88
  mac-upkeep setup # Print sudoers rules
90
- mac-upkeep status # Show brew service status
89
+ mac-upkeep status # Show scheduling dashboard
91
90
  mac-upkeep logs # View last 20 log lines
92
91
  mac-upkeep logs -f # Follow logs
93
92
  mac-upkeep --version # Show version
@@ -43,7 +43,7 @@ uvx mac-upkeep run # one-off without installing
43
43
  Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
44
44
 
45
45
  ```bash
46
- mac-upkeep tasks # See all tasks with frequency and last-run status
46
+ mac-upkeep tasks # See all tasks with status, frequency, and next run
47
47
  ```
48
48
 
49
49
  ## Usage
@@ -54,12 +54,12 @@ mac-upkeep run --dry-run # Preview without executing
54
54
  mac-upkeep run --force brew_update # Run only brew_update
55
55
  mac-upkeep run --force all # Run all, ignoring schedule
56
56
  mac-upkeep run --debug # Verbose output
57
- mac-upkeep tasks # List tasks with status
57
+ mac-upkeep tasks # List tasks with status and next run
58
58
  mac-upkeep init # Generate config (detects your tools)
59
59
  mac-upkeep show-config --default # Show all available task options
60
60
  mac-upkeep show-config # Show your config overrides
61
61
  mac-upkeep setup # Print sudoers rules
62
- mac-upkeep status # Show brew service status
62
+ mac-upkeep status # Show scheduling dashboard
63
63
  mac-upkeep logs # View last 20 log lines
64
64
  mac-upkeep logs -f # Follow logs
65
65
  mac-upkeep --version # Show version
@@ -22,7 +22,7 @@ Adjust versions/paths:
22
22
  - terminal-notifier with osascript fallback for any macOS launchd service needing actionable notifications
23
23
  - `repository_dispatch` + GitHub App for cross-repo automation
24
24
  - `subprocess.run(stdin=subprocess.DEVNULL)` for any CLI orchestrator wrapping interactive tools
25
- - Per-task frequency scheduling with XDG state file + threshold buffers for any periodic CLI tool
25
+ - Per-task frequency scheduling with XDG state file + threshold buffers + humanized next-run/last-run formatting for any periodic CLI tool
26
26
  - `RunAtLoad true` + application-level frequency thresholds for reliable launchd scheduling on laptops — `StartCalendarInterval` does NOT coalesce from power-off (only sleep), so RunAtLoad is the reliable trigger with thresholds preventing over-running
27
27
  - Notification suppression when all tasks skip (`has_activity` guard) — for any RunAtLoad service that would otherwise notify on every boot
28
28
  - newsyslog.d config generation via setup command for any macOS launchd service needing log rotation
@@ -20,7 +20,7 @@ updates, mole system optimization, and Brewfile enforcement.
20
20
  - TOML-driven task registry — add tasks without code changes
21
21
  - Rich Live TUI for interactive use, plain logging for launchd
22
22
  - macOS notifications via terminal-notifier with osascript fallback
23
- - Per-task frequency scheduling (weekly/monthly) with schedule drift buffers
23
+ - Per-task frequency scheduling (weekly/monthly) with schedule drift buffers and next-run visibility
24
24
  - Custom tasks in user config.toml
25
25
 
26
26
  ## Links
@@ -1,25 +1,26 @@
1
1
  [project]
2
2
  name = "mac-upkeep"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Automated macOS maintenance CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
+ license-files = ["LICEN[CS]E*"]
7
8
  requires-python = ">=3.11"
8
9
  dependencies = ["typer>=0.12"]
9
- authors = [{name = "Calvin", url = "https://github.com/calvindotsg"}]
10
+ authors = [{name = "Calvin"}]
10
11
  keywords = ["macos", "maintenance", "homebrew", "cli", "automation", "system-administration", "developer-tools", "cache-management"]
11
12
  classifiers = [
12
13
  "Development Status :: 4 - Beta",
13
14
  "Environment :: Console",
14
15
  "Environment :: MacOS X",
15
16
  "Intended Audience :: Developers",
16
- "License :: OSI Approved :: MIT License",
17
17
  "Operating System :: MacOS",
18
18
  "Programming Language :: Python :: 3",
19
19
  "Programming Language :: Python :: 3.11",
20
20
  "Programming Language :: Python :: 3.12",
21
21
  "Programming Language :: Python :: 3.13",
22
22
  "Topic :: System :: Systems Administration",
23
+ "Typing :: Typed",
23
24
  ]
24
25
 
25
26
  [project.urls]
@@ -27,7 +28,6 @@ Homepage = "https://github.com/calvindotsg/mac-upkeep"
27
28
  Repository = "https://github.com/calvindotsg/mac-upkeep"
28
29
  Issues = "https://github.com/calvindotsg/mac-upkeep/issues"
29
30
  Changelog = "https://github.com/calvindotsg/mac-upkeep/blob/main/CHANGELOG.md"
30
- Documentation = "https://github.com/calvindotsg/mac-upkeep#readme"
31
31
 
32
32
  [project.scripts]
33
33
  mac-upkeep = "mac_upkeep.cli:app"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import getpass
6
6
  import importlib.resources
7
+ import json
7
8
  import logging
8
9
  import os
9
10
  import shutil
@@ -27,7 +28,7 @@ from mac_upkeep.config import (
27
28
  )
28
29
  from mac_upkeep.notify import detect_terminal_bundle_id, format_summary, notify
29
30
  from mac_upkeep.output import Output
30
- from mac_upkeep.tasks import TASKS, _load_state, run_all_tasks
31
+ from mac_upkeep.tasks import TASKS, _load_state, format_last_run, format_next_run, run_all_tasks
31
32
 
32
33
  app = typer.Typer(
33
34
  help="Automated macOS mac-upkeep CLI.\n\n"
@@ -188,20 +189,35 @@ def tasks() -> None:
188
189
  table.add_column("Task", min_width=14)
189
190
  table.add_column("Description", min_width=20)
190
191
  table.add_column("Frequency", min_width=8)
191
- table.add_column("Enabled", min_width=7)
192
+ table.add_column("Status", min_width=8)
192
193
  table.add_column("Last Run", min_width=10)
194
+ table.add_column("Next Run", min_width=10)
193
195
 
194
196
  for name, td in task_list:
195
- enabled = "[green]yes[/green]" if td.enabled else "[dim]no[/dim]"
196
- last_run = state.get(name, "never")
197
- table.add_row(name, td.description, td.frequency, enabled, last_run)
197
+ if not td.enabled:
198
+ status = "[dim]disabled[/dim]"
199
+ elif shutil.which(td.detect) is None:
200
+ status = "[yellow]not found[/yellow]"
201
+ else:
202
+ status = "[green]ready[/green]"
203
+ last_run = format_last_run(state.get(name))
204
+ next_run = "[dim]—[/dim]" if not td.enabled else format_next_run(name, config, state)
205
+ table.add_row(name, td.description, td.frequency, status, last_run, next_run)
198
206
 
199
207
  Console(highlight=False).print(table)
200
208
  else:
201
209
  for name, td in task_list:
202
- enabled = "yes" if td.enabled else "no"
203
- last_run = state.get(name, "never")
204
- typer.echo(f"{name}\t{td.description}\t{td.frequency}\t{enabled}\t{last_run}")
210
+ if not td.enabled:
211
+ status = "disabled"
212
+ elif shutil.which(td.detect) is None:
213
+ status = "not found"
214
+ else:
215
+ status = "ready"
216
+ last_run = format_last_run(state.get(name))
217
+ next_run = "—" if not td.enabled else format_next_run(name, config, state)
218
+ typer.echo(
219
+ f"{name}\t{td.description}\t{td.frequency}\t{status}\t{last_run}\t{next_run}"
220
+ )
205
221
 
206
222
 
207
223
  @app.command()
@@ -399,19 +415,159 @@ def setup() -> None:
399
415
  typer.echo("# | sudo tee /etc/newsyslog.d/mac-upkeep.conf")
400
416
 
401
417
 
402
- @app.command()
403
- def status() -> None:
404
- """Show brew service status for mac-upkeep."""
418
+ def _get_service_info() -> dict | None:
419
+ """Query brew services info as JSON. Returns None on failure."""
405
420
  try:
406
421
  result = subprocess.run(
407
- ["brew", "services", "info", "mac-upkeep"],
422
+ ["brew", "services", "info", "mac-upkeep", "--json"],
408
423
  capture_output=True,
409
424
  text=True,
410
425
  )
411
- typer.echo(result.stdout.strip())
412
- except FileNotFoundError:
413
- typer.echo("brew not found. Install Homebrew first.")
414
- raise typer.Exit(1)
426
+ if result.returncode != 0:
427
+ return None
428
+ data = json.loads(result.stdout)
429
+ return data[0] if data else None
430
+ except (FileNotFoundError, json.JSONDecodeError, IndexError, OSError):
431
+ return None
432
+
433
+
434
+ _WEEKDAY_NAMES = [
435
+ "Sunday",
436
+ "Monday",
437
+ "Tuesday",
438
+ "Wednesday",
439
+ "Thursday",
440
+ "Friday",
441
+ "Saturday",
442
+ ]
443
+
444
+
445
+ def _format_cron_schedule(cron: dict, loaded: bool) -> str:
446
+ """Convert launchd cron dict + loaded flag to schedule string."""
447
+ wd = cron.get("Weekday")
448
+ hour = cron.get("Hour", 0)
449
+ minute = cron.get("Minute", 0)
450
+ day_name = _WEEKDAY_NAMES[wd] if wd is not None and 0 <= wd <= 6 else "?"
451
+ period = "AM" if hour < 12 else "PM"
452
+ h12 = hour % 12 or 12
453
+ schedule = f"Every {day_name} at {h12}:{minute:02d} {period}"
454
+ if loaded:
455
+ schedule += " + on boot"
456
+ return schedule
457
+
458
+
459
+ def _next_trigger_date(cron: dict) -> str:
460
+ """Compute next launchd trigger date from cron weekday. Returns 'Mon Apr 14' style."""
461
+ from datetime import date, timedelta
462
+
463
+ launchd_wd = cron.get("Weekday")
464
+ if launchd_wd is None:
465
+ return "—"
466
+ # launchd: 0=Sunday, 1=Monday, ..., 6=Saturday
467
+ # Python weekday: 0=Monday, ..., 6=Sunday → py_wd = (launchd_wd - 1) % 7
468
+ py_wd = (launchd_wd - 1) % 7
469
+ today = date.today()
470
+ days_ahead = (py_wd - today.weekday()) % 7
471
+ next_date = today + timedelta(days=days_ahead)
472
+ return next_date.strftime("%a %b %-d")
473
+
474
+
475
+ @app.command()
476
+ def status() -> None:
477
+ """Show scheduling dashboard: service state, schedule, and tasks due."""
478
+ try:
479
+ v = pkg_version("mac-upkeep")
480
+ except Exception:
481
+ v = "unknown"
482
+
483
+ config = Config.load()
484
+ state = _load_state()
485
+ svc = _get_service_info()
486
+
487
+ task_list = [
488
+ (name, config.task_defs[name]) for name in config.run_order if name in config.task_defs
489
+ ]
490
+ total = len(task_list)
491
+ ready_count = disabled_count = not_found_count = 0
492
+ overdue: list = []
493
+ due_soon: list = []
494
+
495
+ for name, td in task_list:
496
+ if not td.enabled:
497
+ disabled_count += 1
498
+ continue
499
+ if shutil.which(td.detect) is None:
500
+ not_found_count += 1
501
+ continue
502
+ ready_count += 1
503
+ next_str = format_next_run(name, config, state)
504
+ last_str = format_last_run(state.get(name))
505
+ if next_str == "now":
506
+ overdue.append((name, td, last_str, next_str))
507
+ elif next_str in ("in 1 day", "in 2 days"):
508
+ due_soon.append((name, td, last_str, next_str))
509
+
510
+ tasks_needing_attention = overdue + due_soon
511
+
512
+ summary_parts = [f"{total} tasks", f"{ready_count} ready"]
513
+ if disabled_count:
514
+ summary_parts.append(f"{disabled_count} disabled")
515
+ if not_found_count:
516
+ summary_parts.append(f"{not_found_count} not found")
517
+ if overdue:
518
+ summary_parts.append(f"{len(overdue)} overdue")
519
+ summary_line = ", ".join(summary_parts)
520
+
521
+ if sys.stdout.isatty():
522
+ from rich.console import Console
523
+
524
+ console = Console(highlight=False)
525
+ console.print(f"[bold]mac-upkeep v{v}[/bold]")
526
+ console.print()
527
+ if svc:
528
+ svc_status = svc.get("status", "unknown")
529
+ exit_code = svc.get("exit_code", "?")
530
+ cron = svc.get("cron")
531
+ loaded = svc.get("loaded", False)
532
+ exit_str = f"{exit_code} (success)" if exit_code == 0 else str(exit_code)
533
+ console.print(f" [dim]Service [/dim] {svc_status}")
534
+ if cron:
535
+ console.print(f" [dim]Schedule [/dim] {_format_cron_schedule(cron, loaded)}")
536
+ console.print(f" [dim]Last exit[/dim] {exit_str}")
537
+ console.print()
538
+ if tasks_needing_attention:
539
+ console.print(" [bold]Tasks due:[/bold]")
540
+ for name, td, last_str, next_str in tasks_needing_attention:
541
+ if next_str == "now":
542
+ next_display = "[red]⚠ overdue[/red]"
543
+ else:
544
+ next_display = f"[yellow]{next_str}[/yellow]"
545
+ console.print(
546
+ f" {name:<18} {td.frequency:<8} last: {last_str:<16} {next_display}"
547
+ )
548
+ console.print()
549
+ console.print(f" {summary_line}")
550
+ else:
551
+ cron = svc.get("cron") if svc else None
552
+ if cron:
553
+ next_trigger = _next_trigger_date(cron)
554
+ console.print(
555
+ f" [green]{summary_line} up to date[/green], next run {next_trigger}"
556
+ )
557
+ else:
558
+ console.print(f" [green]{summary_line} up to date[/green]")
559
+ else:
560
+ header_parts = [f"mac-upkeep v{v}"]
561
+ if svc:
562
+ header_parts.append(svc.get("status", "unknown"))
563
+ exit_code = svc.get("exit_code", "?")
564
+ header_parts.append(f"exit: {exit_code}")
565
+ cron = svc.get("cron")
566
+ if cron:
567
+ next_trigger = _next_trigger_date(cron)
568
+ header_parts.append(f"next: {next_trigger}")
569
+ typer.echo(" | ".join(header_parts))
570
+ typer.echo(summary_line)
415
571
 
416
572
 
417
573
  @app.command()
@@ -70,6 +70,43 @@ def _update_last_run(task_key: str) -> None:
70
70
  _save_state(state)
71
71
 
72
72
 
73
+ def format_last_run(last_run_str: str | None) -> str:
74
+ """Humanize a last-run ISO timestamp: 'today', '1 day ago', 'N days ago', or 'never'."""
75
+ if not last_run_str:
76
+ return "never"
77
+ try:
78
+ last_run = datetime.fromisoformat(last_run_str)
79
+ except ValueError:
80
+ return "never"
81
+ days = (datetime.now() - last_run).days
82
+ if days == 0:
83
+ return "today"
84
+ if days == 1:
85
+ return "1 day ago"
86
+ return f"{days} days ago"
87
+
88
+
89
+ def format_next_run(task_key: str, config: Config, state: dict[str, str] | None = None) -> str:
90
+ """Return relative time until task is next eligible: 'now', 'in 1 day', 'in N days'."""
91
+ if state is None:
92
+ state = _load_state()
93
+ last_run_str = state.get(task_key)
94
+ if not last_run_str:
95
+ return "now"
96
+ try:
97
+ last_run = datetime.fromisoformat(last_run_str)
98
+ except ValueError:
99
+ return "now"
100
+ frequency = config.get_frequency(task_key)
101
+ threshold = FREQUENCY_THRESHOLDS.get(frequency, 6)
102
+ remaining = threshold - (datetime.now() - last_run).days
103
+ if remaining <= 0:
104
+ return "now"
105
+ if remaining == 1:
106
+ return "in 1 day"
107
+ return f"in {remaining} days"
108
+
109
+
73
110
  def strip_ansi(text: str) -> str:
74
111
  """Remove ANSI color codes from text."""
75
112
  return ANSI_PATTERN.sub("", text)
@@ -175,7 +212,8 @@ def _run(
175
212
 
176
213
  # Frequency: skip if ran recently (no --force = frequency applies)
177
214
  if not dry_run and force_tasks is None and not _should_run(task_key, config):
178
- result = TaskResult(name, "skipped", reason="ran recently")
215
+ next_str = format_next_run(task_key, config)
216
+ result = TaskResult(name, "skipped", reason=f"ran recently, next {next_str}")
179
217
  output.task_done(result)
180
218
  return result
181
219
 
File without changes
@@ -50,6 +50,19 @@ def test_tasks_command():
50
50
  assert result.exit_code == 0
51
51
  for name in ["brew_update", "gcloud", "mo_clean", "brew_bundle"]:
52
52
  assert name in result.output
53
+ assert "ready" in result.output
54
+
55
+
56
+ def test_tasks_shows_not_found_status(tmp_path):
57
+ config_path = tmp_path / "config.toml"
58
+ with (
59
+ patch("mac_upkeep.cli.DEFAULT_CONFIG_PATH", config_path),
60
+ patch("mac_upkeep.config.get_brew_prefix", return_value="/opt/homebrew"),
61
+ patch("mac_upkeep.cli.shutil.which", return_value=None),
62
+ ):
63
+ result = runner.invoke(app, ["tasks"])
64
+ assert result.exit_code == 0
65
+ assert "not found" in result.output
53
66
 
54
67
 
55
68
  def test_force_invalid_shows_valid_tasks():
@@ -207,6 +220,51 @@ def test_run_no_notification_when_all_skipped(tmp_path, monkeypatch):
207
220
  mock_notify.assert_not_called()
208
221
 
209
222
 
223
+ # --- status command ---
224
+
225
+ _MOCK_SVC_INFO = {
226
+ "name": "mac-upkeep",
227
+ "running": False,
228
+ "loaded": True,
229
+ "exit_code": 0,
230
+ "status": "scheduled",
231
+ "cron": {"Month": "*", "Day": "*", "Weekday": 1, "Hour": 12, "Minute": 0},
232
+ }
233
+
234
+
235
+ def test_status_shows_service_info():
236
+ """Status shows service state when brew services is available."""
237
+ with patch("mac_upkeep.cli._get_service_info", return_value=_MOCK_SVC_INFO):
238
+ result = runner.invoke(app, ["status"])
239
+ assert result.exit_code == 0
240
+ assert "mac-upkeep v" in result.output
241
+ assert "scheduled" in result.output
242
+ assert "exit: 0" in result.output
243
+ assert "tasks" in result.output
244
+
245
+
246
+ def test_status_graceful_degradation():
247
+ """Status shows task summary even when brew services is unavailable."""
248
+ with patch("mac_upkeep.cli._get_service_info", return_value=None):
249
+ result = runner.invoke(app, ["status"])
250
+ assert result.exit_code == 0
251
+ assert "mac-upkeep v" in result.output
252
+ assert "tasks" in result.output
253
+
254
+
255
+ def test_status_overdue_tasks(tmp_path, monkeypatch):
256
+ """Tasks that have never run appear as overdue in summary."""
257
+ state_file = tmp_path / "last-run.json"
258
+ state_file.write_text("{}") # empty state — all tasks eligible
259
+ monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
260
+
261
+ with patch("mac_upkeep.cli._get_service_info", return_value=None):
262
+ result = runner.invoke(app, ["status"])
263
+
264
+ assert result.exit_code == 0
265
+ assert "overdue" in result.output
266
+
267
+
210
268
  def test_run_sends_notification_on_activity(tmp_path, monkeypatch):
211
269
  """Scheduled run: at least one task ran → notification sent."""
212
270
  state_file = tmp_path / "last-run.json"
@@ -14,6 +14,8 @@ from mac_upkeep.tasks import (
14
14
  _run,
15
15
  _should_run,
16
16
  _update_last_run,
17
+ format_last_run,
18
+ format_next_run,
17
19
  run_all_tasks,
18
20
  run_task,
19
21
  strip_ansi,
@@ -246,7 +248,7 @@ def test_run_frequency_skip(tmp_path, monkeypatch):
246
248
  detect="gcloud",
247
249
  )
248
250
  assert result.status == "skipped"
249
- assert result.reason == "ran recently"
251
+ assert result.reason.startswith("ran recently, next")
250
252
 
251
253
 
252
254
  @patch("mac_upkeep.tasks.subprocess.run")
@@ -365,3 +367,68 @@ def test_failed_task_no_timestamp_update(mock_which, mock_run, tmp_path, monkeyp
365
367
  )
366
368
  assert result.status == "failed"
367
369
  assert not state_file.exists()
370
+
371
+
372
+ # --- format_last_run ---
373
+
374
+
375
+ def test_format_last_run_none():
376
+ assert format_last_run(None) == "never"
377
+
378
+
379
+ def test_format_last_run_corrupt():
380
+ assert format_last_run("not-a-date") == "never"
381
+
382
+
383
+ def test_format_last_run_today():
384
+ ts = datetime.now().isoformat(timespec="seconds")
385
+ assert format_last_run(ts) == "today"
386
+
387
+
388
+ def test_format_last_run_one_day_ago():
389
+ ts = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
390
+ assert format_last_run(ts) == "1 day ago"
391
+
392
+
393
+ def test_format_last_run_many_days_ago():
394
+ ts = (datetime.now() - timedelta(days=5)).isoformat(timespec="seconds")
395
+ assert format_last_run(ts) == "5 days ago"
396
+
397
+
398
+ # --- format_next_run ---
399
+
400
+
401
+ def test_format_next_run_never_ran(tmp_path, monkeypatch):
402
+ """Never ran → immediately eligible → 'now'."""
403
+ monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", tmp_path / "last-run.json")
404
+ config = Config.load()
405
+ assert format_next_run("gcloud", config) == "now"
406
+
407
+
408
+ def test_format_next_run_within_threshold(tmp_path, monkeypatch):
409
+ """gcloud is monthly (27-day threshold), ran 1 day ago → 26 days remaining."""
410
+ state_file = tmp_path / "last-run.json"
411
+ recent = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
412
+ state_file.write_text(json.dumps({"gcloud": recent}))
413
+ monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
414
+ config = Config.load()
415
+ assert format_next_run("gcloud", config) == "in 26 days"
416
+
417
+
418
+ def test_format_next_run_past_threshold(tmp_path, monkeypatch):
419
+ """gcloud ran 30 days ago (past 27-day threshold) → 'now'."""
420
+ state_file = tmp_path / "last-run.json"
421
+ old = (datetime.now() - timedelta(days=30)).isoformat(timespec="seconds")
422
+ state_file.write_text(json.dumps({"gcloud": old}))
423
+ monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
424
+ config = Config.load()
425
+ assert format_next_run("gcloud", config) == "now"
426
+
427
+
428
+ def test_format_next_run_with_state_param(tmp_path, monkeypatch):
429
+ """Pre-loaded state dict bypasses file read and produces same result."""
430
+ monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", tmp_path / "last-run.json")
431
+ config = Config.load()
432
+ recent = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
433
+ state = {"gcloud": recent}
434
+ assert format_next_run("gcloud", config, state) == "in 26 days"
@@ -43,7 +43,7 @@ wheels = [
43
43
 
44
44
  [[package]]
45
45
  name = "mac-upkeep"
46
- version = "2.2.0"
46
+ version = "2.3.0"
47
47
  source = { editable = "." }
48
48
  dependencies = [
49
49
  { name = "typer" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "2.2.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes