wuti-py-macos 0.2.8__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WUTI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: wuti-py-macos
3
+ Version: 0.2.8
4
+ Summary: Clean, Pythonic utilities for macOS system operations
5
+ Project-URL: Homepage, https://github.com/wuti-labs
6
+ Author-email: Alex Qin <qinqinghai@hotmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: daemon,dock,finder,macos,pasteboard,system,utility
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: MacOS :: MacOS X
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: pydantic>=2.0.0
23
+ Requires-Dist: wuti-py-util>=0.6.0
@@ -0,0 +1,99 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wuti-py-macos"
7
+ dynamic = ["version"]
8
+ description = "Clean, Pythonic utilities for macOS system operations"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Alex Qin", email = "qinqinghai@hotmail.com" }]
12
+ keywords = ["macos", "system", "utility", "pasteboard", "finder", "dock", "daemon"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS :: MacOS X",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = [
27
+ "wuti-py-util>=0.6.0",
28
+ "pydantic>=2.0.0",
29
+ ]
30
+
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/wuti-labs"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "build",
38
+ "coverage[toml]>=7.13",
39
+ "hatchling",
40
+ "mypy>=1.18",
41
+ "pytest>=9.0.2",
42
+ "pytest-cov>=7.0",
43
+ "ruff>=0.15",
44
+ ]
45
+
46
+ [tool.hatch.version]
47
+ path = "src/wuti/macos/__init__.py"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/wuti"]
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ ignore-vcs = true
54
+ only-include = ["src/wuti"]
55
+ exclude = [
56
+ "/.github",
57
+ "/tests",
58
+ "/README.md",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.sdist.hooks.custom]
62
+ path = "scripts/hatch_sdist_hook.py"
63
+
64
+ [tool.ruff]
65
+ line-length = 140
66
+ target-version = "py310"
67
+ src = ["src", "tests"]
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E4", "E7", "E9", "F", "B"]
71
+
72
+ [tool.ruff.lint.per-file-ignores]
73
+ "tests/**" = ["B017", "F841"]
74
+
75
+ [tool.mypy]
76
+ python_version = "3.10"
77
+ files = ["src"]
78
+ mypy_path = "src"
79
+ explicit_package_bases = true
80
+ namespace_packages = true
81
+ ignore_missing_imports = true
82
+ warn_unused_configs = true
83
+ check_untyped_defs = true
84
+ allow_redefinition = true
85
+
86
+ [tool.pytest.ini_options]
87
+ pythonpath = ["src"]
88
+ testpaths = ["tests"]
89
+ addopts = "-v --cov=src --cov-branch --cov-report=html --cov-report=term --cov-report=term-missing"
90
+
91
+ [tool.coverage.run]
92
+ source = ["src"]
93
+ omit = ["*/tests/*"]
94
+ branch = true
95
+ relative_files = true
96
+
97
+ [tool.coverage.report]
98
+ exclude_lines = ["pragma: no cover", "def __repr__"]
99
+ fail_under = 100
@@ -0,0 +1,29 @@
1
+ """wuti-py-macos: A clean and simple Python library for macOS system operations.
2
+
3
+ This package provides utilities for managing various macOS system components
4
+ including Finder, Dock, Launchpad, daemons, pasteboard, and more.
5
+ """
6
+
7
+ from . import (
8
+ cleanup,
9
+ daemon,
10
+ dock,
11
+ finder,
12
+ pasteboard,
13
+ pkgutil,
14
+ utils,
15
+ )
16
+
17
+ __version__ = "0.2.8"
18
+ __author__ = "Alex Qin"
19
+ __email__ = "qinqinghai@hotmail.com"
20
+
21
+ __all__ = [
22
+ "cleanup",
23
+ "daemon",
24
+ "dock",
25
+ "finder",
26
+ "pasteboard",
27
+ "pkgutil",
28
+ "utils",
29
+ ]
@@ -0,0 +1,187 @@
1
+ """Module for system file and cache cleanup on macOS.
2
+
3
+ This module provides functions for cleaning up:
4
+ - Desktop Service Store (.DS_Store) files
5
+ - Icon cache files
6
+ - Icon services cache
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from typing import Optional, Tuple
13
+
14
+ from wuti.util.log import notice, warning
15
+
16
+ from .constants import ICON_SERVICES_CACHE_PATH
17
+ from .finder import Finder, get_system_paths
18
+ from .utils import CommandRunner, handle_keyboard_interrupt, handle_runtime_error
19
+
20
+ # Cleanup patterns
21
+ DS_STORE_PATTERNS = [".DS_Store", "**/.DS_Store"]
22
+ ICONCACHE_PATTERNS = ["**/com.apple.dock.iconcache"]
23
+ ICONSERVICES_PATTERNS = ["**/com.apple.iconservices"]
24
+
25
+ _CLEANUP_DS_STORE_OPERATION = "cleanup Desktop Service Store files"
26
+
27
+ _finder_instance: Optional[Finder] = None
28
+
29
+
30
+ @dataclass
31
+ class PruningMetrics:
32
+ removed_files: int = 0
33
+ removed_dirs: int = 0
34
+
35
+ def print_summary(self) -> None:
36
+ notice(f"Cleaned {self.removed_files} Desktop Service Store files; removed {self.removed_dirs} empty directories.")
37
+
38
+
39
+ def _delete_ds_store_files(root: str, stats: PruningMetrics) -> None:
40
+ """Remove ``.DS_Store`` under *root* (top-down: shallow first)."""
41
+ for dirpath, _dirs, filenames in os.walk(root, topdown=True, followlinks=False):
42
+ if ".DS_Store" not in filenames:
43
+ continue
44
+ target = os.path.join(dirpath, ".DS_Store")
45
+ try:
46
+ os.remove(target)
47
+ stats.removed_files += 1
48
+ except OSError as e:
49
+ warning(f"Failed to remove '{target}': {e}")
50
+
51
+
52
+ def _rmdir_empty_dirs_under(root: str, stats: PruningMetrics) -> None:
53
+ """Remove empty directories under *root*, excluding *root* itself (bottom-up)."""
54
+ for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False):
55
+ if os.path.realpath(dirpath) == root:
56
+ continue
57
+ try:
58
+ if os.listdir(dirpath):
59
+ continue
60
+ os.rmdir(dirpath)
61
+ stats.removed_dirs += 1
62
+ except OSError as e:
63
+ warning(f"Failed to rmdir '{dirpath}': {e}")
64
+
65
+
66
+ def _get_finder() -> Finder:
67
+ """Get or create a cached Finder instance.
68
+
69
+ Returns:
70
+ Finder instance for file operations.
71
+ """
72
+ global _finder_instance
73
+ if _finder_instance is None:
74
+ _finder_instance = Finder()
75
+ return _finder_instance
76
+
77
+
78
+ def _private_var_folders() -> str:
79
+ """Path under which icon cache / icon services cleanup runs."""
80
+ return get_system_paths()["private_var_folders"]
81
+
82
+
83
+ @handle_runtime_error(_CLEANUP_DS_STORE_OPERATION)
84
+ def cleanup_ds_store(*, remove_empty_dirs: bool = False) -> Tuple[int, int]:
85
+ """Clean up macOS Desktop Service Store files.
86
+
87
+ Args:
88
+ remove_empty_dirs: If True, remove directories that become empty after .DS_Store removal
89
+ (excluding the scan root). If False, only delete .DS_Store files.
90
+
91
+ Returns:
92
+ Tuple of (number of .DS_Store files removed, number of empty directories removed).
93
+
94
+ Raises:
95
+ RuntimeError: If the cleanup operation fails.
96
+ KeyboardInterrupt: Re-raised after printing a summary of progress so far.
97
+
98
+ Note:
99
+ On Ctrl+C, prints the same summary line as on normal completion, then re-raises.
100
+ """
101
+ root = os.path.abspath(".")
102
+ stats = PruningMetrics()
103
+
104
+ def _on_interrupt(_: str) -> None:
105
+ stats.print_summary()
106
+
107
+ @handle_keyboard_interrupt(_CLEANUP_DS_STORE_OPERATION, on_interrupt=_on_interrupt)
108
+ def _run_phases() -> None:
109
+ _delete_ds_store_files(root, stats)
110
+ if remove_empty_dirs:
111
+ _rmdir_empty_dirs_under(root, stats)
112
+
113
+ _run_phases()
114
+ stats.print_summary()
115
+ return (stats.removed_files, stats.removed_dirs)
116
+
117
+
118
+ def superuser_clean_icon_services() -> None:
119
+ """Delete the icon services cache (requires superuser privileges).
120
+
121
+ Raises:
122
+ subprocess.CalledProcessError: If the rm command fails.
123
+ RuntimeError: If icon services cache cannot be cleaned.
124
+ """
125
+ try:
126
+ CommandRunner.run(["rm", "-rf", ICON_SERVICES_CACHE_PATH], check=True)
127
+ notice("Icon services cache has been cleaned (superuser).")
128
+ except subprocess.CalledProcessError as e:
129
+ raise RuntimeError(f"Failed to clean icon services cache: {e.stderr}") from e
130
+ except Exception as e:
131
+ raise RuntimeError(f"Failed to clean icon services cache: {e}") from e
132
+
133
+
134
+ @handle_runtime_error("clean icon cache")
135
+ def superuser_clean_icon_cache() -> None:
136
+ """Delete the icon caches (requires superuser privileges).
137
+
138
+ Raises:
139
+ RuntimeError: If icon cache cannot be cleaned.
140
+ """
141
+ finder = _get_finder()
142
+ finder.cleanup_recursive(_private_var_folders(), ICONCACHE_PATTERNS)
143
+ notice("Icon cache has been cleaned (superuser).")
144
+
145
+
146
+ @handle_runtime_error("find icon cache folders")
147
+ def find_icon_cache() -> list[str]:
148
+ """Find the icon cache folders.
149
+
150
+ Returns:
151
+ List of icon cache folder paths.
152
+
153
+ Raises:
154
+ RuntimeError: If icon cache folders cannot be found.
155
+ """
156
+ finder = _get_finder()
157
+ return finder.find_recursive(_private_var_folders(), ICONCACHE_PATTERNS)
158
+
159
+
160
+ @handle_runtime_error("clean icon services cache")
161
+ def clean_icon_services() -> int:
162
+ """Delete the icon services cache.
163
+
164
+ Returns:
165
+ Number of icon service items cleaned.
166
+
167
+ Raises:
168
+ RuntimeError: If icon services cache cannot be cleaned.
169
+ """
170
+ finder = _get_finder()
171
+ cleanup_count = finder.cleanup_recursive(_private_var_folders(), ICONSERVICES_PATTERNS)
172
+ notice(f"Cleaned {cleanup_count} icon service items.")
173
+ return cleanup_count
174
+
175
+
176
+ @handle_runtime_error("find icon services cache folders")
177
+ def find_icon_services() -> list[str]:
178
+ """Find the icon services cache folders.
179
+
180
+ Returns:
181
+ List of icon services cache folder paths.
182
+
183
+ Raises:
184
+ RuntimeError: If icon services cache folders cannot be found.
185
+ """
186
+ finder = _get_finder()
187
+ return finder.find_recursive(_private_var_folders(), ICONSERVICES_PATTERNS)
@@ -0,0 +1,15 @@
1
+ """Shared constants for wuti.macos."""
2
+
3
+ # Process names
4
+ FINDER = "Finder"
5
+ DOCK = "Dock"
6
+
7
+ # Preference domains
8
+ DOMAIN_DOCK = "com.apple.dock"
9
+ DOMAIN_FINDER = "com.apple.finder"
10
+
11
+ # Delays
12
+ APP_RESTART_DELAY = 0.2
13
+
14
+ # Paths
15
+ ICON_SERVICES_CACHE_PATH = "/Library/Caches/com.apple.iconservices.store"
@@ -0,0 +1,280 @@
1
+ """Module for managing daemons and agents on macOS."""
2
+
3
+ import glob
4
+ import os
5
+ from functools import cached_property
6
+ from typing import Optional
7
+
8
+ from wuti.util.log import debug, indent, notice, unindent, warning
9
+ from wuti.util.validation import validate_string_input
10
+
11
+ from .finder import Finder, get_daemon_paths
12
+ from .utils import CommandRunner, handle_runtime_error
13
+
14
+
15
+ class Daemon:
16
+ """Manages daemons and agents on macOS.
17
+
18
+ Provides comprehensive methods to inspect, manage, and control macOS daemons and agents
19
+ through their configuration files in various LaunchDaemons and LaunchAgents directories.
20
+
21
+ This class offers functionality to:
22
+ - List all daemons and agents
23
+ - Search for specific daemons/agents
24
+ - Get detailed information about daemon configurations
25
+ - Control daemon states (load/unload)
26
+ - Filter and categorize daemons by type
27
+ """
28
+
29
+ def __init__(self):
30
+ """Initialize the Daemon instance."""
31
+ debug("Initializing Daemon instance")
32
+
33
+ @cached_property
34
+ def finder(self) -> Finder:
35
+ """The finder object for file operations.
36
+
37
+ Returns:
38
+ Finder: An instance for file operations.
39
+ """
40
+ return Finder()
41
+
42
+ @cached_property
43
+ def daemon_paths(self) -> dict[str, str]:
44
+ """Daemon and agent paths.
45
+
46
+ Returns:
47
+ dict[str, str]: A dictionary mapping path names to their absolute paths.
48
+ """
49
+ return get_daemon_paths()
50
+
51
+ @handle_runtime_error("print daemon and agent information")
52
+ def print_all(self) -> None:
53
+ """Prints all daemons and agents from all configured paths.
54
+
55
+ Raises:
56
+ RuntimeError: If any daemon or agent directory cannot be accessed.
57
+ """
58
+ debug("Printing all daemons and agents")
59
+ for path_name, path in self.daemon_paths.items():
60
+ notice(f"=== {path_name.replace('_', ' ').title()} ===")
61
+ try:
62
+ self.finder.print_flat_list(path)
63
+ except Exception as e:
64
+ warning(f"Failed to access {path_name} at '{path}': {e}")
65
+ continue
66
+
67
+ def get_daemon_files(self, path_name: Optional[str] = None) -> list[str]:
68
+ """Get all daemon/agent plist files from specified or all paths.
69
+
70
+ Args:
71
+ path_name: Specific path name to search in. If None, searches all paths.
72
+
73
+ Returns:
74
+ list[str]: List of plist file paths.
75
+
76
+ Raises:
77
+ ValueError: If path_name is invalid.
78
+ RuntimeError: If file listing fails.
79
+ """
80
+ debug(f"Getting daemon files from path: {path_name or 'all'}")
81
+
82
+ all_files = []
83
+
84
+ if path_name:
85
+ if path_name not in self.daemon_paths:
86
+ raise ValueError(f"Invalid path name '{path_name}'. Available paths: {list(self.daemon_paths.keys())}")
87
+ paths_to_search = {path_name: self.daemon_paths[path_name]}
88
+ else:
89
+ paths_to_search = self.daemon_paths
90
+
91
+ for path_name, path in paths_to_search.items():
92
+ try:
93
+ if os.path.exists(path):
94
+ plist_files = glob.glob(os.path.join(path, "*.plist"))
95
+ all_files.extend(plist_files)
96
+ debug(f"Found {len(plist_files)} plist files in {path_name}")
97
+ else:
98
+ warning(f"Path does not exist: {path}")
99
+ except Exception as e:
100
+ warning(f"Failed to list files in {path_name} at '{path}': {e}")
101
+
102
+ return sorted(all_files)
103
+
104
+ def search_daemons(self, pattern: str, path_name: Optional[str] = None) -> list[str]:
105
+ """Search for daemons/agents matching a pattern.
106
+
107
+ Args:
108
+ pattern: Search pattern to match against plist filenames.
109
+ path_name: Specific path name to search in. If None, searches all paths.
110
+
111
+ Returns:
112
+ list[str]: List of matching plist file paths.
113
+
114
+ Raises:
115
+ ValueError: If pattern is empty or path_name is invalid.
116
+ """
117
+ pattern = validate_string_input(pattern, "pattern")
118
+ debug(f"Searching for daemons matching pattern: '{pattern}'")
119
+
120
+ all_files = self.get_daemon_files(path_name)
121
+ matching_files = [f for f in all_files if pattern.lower() in os.path.basename(f).lower()]
122
+
123
+ debug(f"Found {len(matching_files)} matching files")
124
+ return matching_files
125
+
126
+ def get_daemon_info(self, plist_path: str) -> dict[str, str | bool | int | float | None]:
127
+ """Get detailed information about a daemon/agent from its plist file.
128
+
129
+ Args:
130
+ plist_path: Path to the plist file.
131
+
132
+ Returns:
133
+ dict containing daemon information including:
134
+ - name: Daemon name
135
+ - path: Full path to plist
136
+ - exists: Whether the file exists
137
+ - size: File size in bytes
138
+ - modified: Last modification time
139
+
140
+ Raises:
141
+ ValueError: If plist_path is invalid.
142
+ """
143
+ plist_path = validate_string_input(plist_path, "plist_path")
144
+ debug(f"Getting daemon info for: {plist_path}")
145
+
146
+ info: dict[str, str | bool | int | float | None] = {
147
+ "name": os.path.basename(plist_path),
148
+ "path": plist_path,
149
+ "exists": False,
150
+ "size": 0,
151
+ "modified": None,
152
+ }
153
+
154
+ try:
155
+ if os.path.exists(plist_path):
156
+ stat_info = os.stat(plist_path)
157
+ info.update({"exists": True, "size": stat_info.st_size, "modified": stat_info.st_mtime})
158
+ except OSError as e:
159
+ warning(f"Failed to get info for {plist_path}: {e}")
160
+
161
+ return info
162
+
163
+ def get_daemon_status(self, daemon_name: str) -> dict[str, str | bool]:
164
+ """Get the current status of a daemon/agent.
165
+
166
+ Args:
167
+ daemon_name: Name of the daemon (without .plist extension).
168
+
169
+ Returns:
170
+ dict containing status information:
171
+ - loaded: Whether the daemon is currently loaded
172
+ - status: Human-readable status string
173
+
174
+ Raises:
175
+ ValueError: If daemon_name is invalid.
176
+ """
177
+ daemon_name = validate_string_input(daemon_name, "daemon_name")
178
+ debug(f"Getting status for daemon: {daemon_name}")
179
+
180
+ # Remove .plist extension if present
181
+ if daemon_name.endswith(".plist"):
182
+ daemon_name = daemon_name[:-6]
183
+
184
+ try:
185
+ # Use launchctl to check if daemon is loaded
186
+ result = CommandRunner.run(["launchctl", "list", daemon_name], check=False)
187
+ loaded = result.returncode == 0 and daemon_name in result.stdout
188
+
189
+ status = "Loaded" if loaded else "Not loaded"
190
+
191
+ return {"loaded": loaded, "status": status, "name": daemon_name}
192
+ except Exception as e:
193
+ warning(f"Failed to get status for daemon {daemon_name}: {e}")
194
+ return {"loaded": False, "status": f"Error: {e}", "name": daemon_name}
195
+
196
+ def load_daemon(self, plist_path: str) -> bool:
197
+ """Load a daemon/agent using launchctl.
198
+
199
+ Args:
200
+ plist_path: Path to the plist file.
201
+
202
+ Returns:
203
+ bool: True if successful, False otherwise.
204
+
205
+ Raises:
206
+ ValueError: If plist_path is invalid.
207
+ """
208
+ plist_path = validate_string_input(plist_path, "plist_path")
209
+ debug(f"Loading daemon from: {plist_path}")
210
+
211
+ if not os.path.exists(plist_path):
212
+ warning(f"Plist file does not exist: {plist_path}")
213
+ return False
214
+
215
+ try:
216
+ CommandRunner.run(["launchctl", "load", plist_path])
217
+ notice(f"Successfully loaded daemon: {os.path.basename(plist_path)}")
218
+ return True
219
+ except Exception as e:
220
+ warning(f"Failed to load daemon {plist_path}: {e}")
221
+ return False
222
+
223
+ def unload_daemon(self, plist_path: str) -> bool:
224
+ """Unload a daemon/agent using launchctl.
225
+
226
+ Args:
227
+ plist_path: Path to the plist file.
228
+
229
+ Returns:
230
+ bool: True if successful, False otherwise.
231
+
232
+ Raises:
233
+ ValueError: If plist_path is invalid.
234
+ """
235
+ plist_path = validate_string_input(plist_path, "plist_path")
236
+ debug(f"Unloading daemon from: {plist_path}")
237
+
238
+ try:
239
+ CommandRunner.run(["launchctl", "unload", plist_path])
240
+ notice(f"Successfully unloaded daemon: {os.path.basename(plist_path)}")
241
+ return True
242
+ except Exception as e:
243
+ warning(f"Failed to unload daemon {plist_path}: {e}")
244
+ return False
245
+
246
+ def get_daemon_summary(self) -> dict[str, int]:
247
+ """Get a summary count of daemons and agents by path.
248
+
249
+ Returns:
250
+ dict[str, int]: Dictionary mapping path names to file counts.
251
+ """
252
+ debug("Generating daemon summary")
253
+ summary = {}
254
+
255
+ for path_name, path in self.daemon_paths.items():
256
+ try:
257
+ if os.path.exists(path):
258
+ plist_files = glob.glob(os.path.join(path, "*.plist"))
259
+ summary[path_name] = len(plist_files)
260
+ else:
261
+ summary[path_name] = 0
262
+ except Exception as e:
263
+ warning(f"Failed to count files in {path_name}: {e}")
264
+ summary[path_name] = 0
265
+
266
+ return summary
267
+
268
+ def print_summary(self) -> None:
269
+ """Print a summary of all daemons and agents by path."""
270
+ debug("Printing daemon summary")
271
+ summary = self.get_daemon_summary()
272
+
273
+ notice("=== Daemon and Agent Summary ===")
274
+ indent()
275
+ total = 0
276
+ for path_name, count in summary.items():
277
+ notice(f"{path_name.replace('_', ' ').title()}: {count} files")
278
+ total += count
279
+ unindent()
280
+ notice(f"Total: {total} daemon/agent files")