mct-cli 0.2.3__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.
mct/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """macOS Configuration Tools - A CLI for managing macOS settings declaratively."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("macos-config-tools")
mct/cli.py ADDED
@@ -0,0 +1,224 @@
1
+ import importlib.metadata
2
+
3
+ import typer
4
+
5
+ from .commands.dock import dock_app
6
+ from .commands.finder import finder_app
7
+ from .commands.keyboard import keyboard_app
8
+ from .commands.screenshot import screenshot_app
9
+ from .commands.system import system_app
10
+ from .config import (
11
+ CONFIG_PATH,
12
+ SETTINGS,
13
+ apply_config,
14
+ compute_diff,
15
+ flatten_config,
16
+ load_config,
17
+ read_current_state,
18
+ save_config,
19
+ unflatten_config,
20
+ )
21
+
22
+ app = typer.Typer()
23
+ app.add_typer(dock_app, name="dock", help="Manage dock settings")
24
+ app.add_typer(finder_app, name="finder", help="Manage Finder settings")
25
+ app.add_typer(keyboard_app, name="keyboard", help="Manage keyboard settings")
26
+ app.add_typer(screenshot_app, name="screenshot", help="Manage screenshot settings")
27
+ app.add_typer(system_app, name="system", help="Manage system settings")
28
+
29
+
30
+ def _version_callback(value: bool):
31
+ if value:
32
+ version = importlib.metadata.version("macos-config-tools")
33
+ typer.echo(f"mct version: {version}")
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def callback(
39
+ version: bool = typer.Option(
40
+ False, "--version", "-v", help="Show the version and exit.", callback=_version_callback
41
+ )
42
+ ):
43
+ """macOS Configuration Tools - Manage macOS system settings declaratively."""
44
+ pass
45
+
46
+
47
+ @app.command()
48
+ def apply(
49
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would change without applying"),
50
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to config file"),
51
+ ):
52
+ """Apply settings from config file to the system."""
53
+ path = config_file if config_file else CONFIG_PATH
54
+
55
+ if config_file:
56
+ from pathlib import Path
57
+ path = Path(config_file)
58
+ if not path.exists():
59
+ typer.echo(f"Error: Config file not found: {path}")
60
+ raise typer.Exit(1)
61
+ import yaml
62
+ with open(path) as f:
63
+ config = yaml.safe_load(f) or {}
64
+ else:
65
+ config = load_config()
66
+
67
+ if not config:
68
+ typer.echo(f"No config file found at {CONFIG_PATH}")
69
+ typer.echo("Run 'mct export' to create one from current settings")
70
+ raise typer.Exit(1)
71
+
72
+ flat_config = flatten_config(config)
73
+
74
+ # Filter to only known settings
75
+ valid_config = {k: v for k, v in flat_config.items() if k in SETTINGS}
76
+ unknown_keys = set(flat_config.keys()) - set(SETTINGS.keys())
77
+
78
+ if unknown_keys:
79
+ typer.echo(f"Warning: Unknown settings will be ignored: {', '.join(sorted(unknown_keys))}")
80
+
81
+ diffs = apply_config(valid_config, dry_run=dry_run)
82
+
83
+ if not diffs:
84
+ typer.echo("System is already in sync with config")
85
+ return
86
+
87
+ if dry_run:
88
+ typer.echo("Changes that would be applied:")
89
+ else:
90
+ typer.echo("Applied changes:")
91
+
92
+ for diff in diffs:
93
+ current = diff.current if diff.current is not None else "(not set)"
94
+ typer.echo(f" {diff.key}: {current} -> {diff.desired}")
95
+
96
+ if dry_run:
97
+ typer.echo(f"\nRun without --dry-run to apply {len(diffs)} change(s)")
98
+
99
+
100
+ @app.command()
101
+ def export(
102
+ output: str = typer.Option(None, "--output", "-o", help="Output file path (default: stdout)"),
103
+ save: bool = typer.Option(False, "--save", "-s", help=f"Save to {CONFIG_PATH}"),
104
+ ):
105
+ """Export current system settings to YAML."""
106
+ import yaml
107
+
108
+ current_state = read_current_state()
109
+ config = unflatten_config(current_state)
110
+
111
+ yaml_output = yaml.dump(config, default_flow_style=False, sort_keys=False)
112
+
113
+ if save:
114
+ save_config(config)
115
+ typer.echo(f"Config saved to {CONFIG_PATH}")
116
+ elif output:
117
+ from pathlib import Path
118
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
119
+ with open(output, "w") as f:
120
+ f.write(yaml_output)
121
+ typer.echo(f"Config exported to {output}")
122
+ else:
123
+ typer.echo(yaml_output)
124
+
125
+
126
+ @app.command()
127
+ def diff(
128
+ config_file: str = typer.Option(None, "--config", "-c", help="Path to config file"),
129
+ ):
130
+ """Show differences between config file and current system state."""
131
+ if config_file:
132
+ from pathlib import Path
133
+ path = Path(config_file)
134
+ if not path.exists():
135
+ typer.echo(f"Error: Config file not found: {path}")
136
+ raise typer.Exit(1)
137
+ import yaml
138
+ with open(path) as f:
139
+ config = yaml.safe_load(f) or {}
140
+ else:
141
+ config = load_config()
142
+
143
+ if not config:
144
+ typer.echo(f"No config file found at {CONFIG_PATH}")
145
+ typer.echo("Run 'mct export --save' to create one")
146
+ raise typer.Exit(1)
147
+
148
+ flat_config = flatten_config(config)
149
+ valid_config = {k: v for k, v in flat_config.items() if k in SETTINGS}
150
+
151
+ diffs = compute_diff(valid_config)
152
+
153
+ if not diffs:
154
+ typer.echo("System is in sync with config")
155
+ return
156
+
157
+ typer.echo(f"Found {len(diffs)} difference(s):\n")
158
+ for d in diffs:
159
+ current = d.current if d.current is not None else "(not set)"
160
+ typer.echo(f" {d.key}:")
161
+ typer.echo(f" current: {current}")
162
+ typer.echo(f" config: {d.desired}")
163
+ typer.echo()
164
+
165
+
166
+ @app.command()
167
+ def settings():
168
+ """List all available settings."""
169
+ typer.echo("Available settings:\n")
170
+
171
+ # Group by category
172
+ categories: dict[str, list[str]] = {}
173
+ for key in sorted(SETTINGS.keys()):
174
+ category = key.split(".")[0]
175
+ if category not in categories:
176
+ categories[category] = []
177
+ categories[category].append(key)
178
+
179
+ for category, keys in categories.items():
180
+ typer.echo(f"{category}:")
181
+ for key in keys:
182
+ setting = SETTINGS[key]
183
+ typer.echo(f" {key}: {setting.description}")
184
+ typer.echo()
185
+
186
+
187
+ @app.command()
188
+ def init():
189
+ """Create a starter config file with common settings."""
190
+ if CONFIG_PATH.exists():
191
+ if not typer.confirm(f"Config already exists at {CONFIG_PATH}. Overwrite?"):
192
+ raise typer.Exit(0)
193
+
194
+ starter_config = {
195
+ "dock": {
196
+ "size": 48,
197
+ "autohide": False,
198
+ "show_recents": False,
199
+ },
200
+ "finder": {
201
+ "show_extensions": True,
202
+ "show_hidden": False,
203
+ "show_path_bar": True,
204
+ },
205
+ "screenshot": {
206
+ "format": "png",
207
+ "disable_shadow": True,
208
+ },
209
+ "keyboard": {
210
+ "press_and_hold": False,
211
+ },
212
+ }
213
+
214
+ save_config(starter_config)
215
+ typer.echo(f"Created starter config at {CONFIG_PATH}")
216
+ typer.echo("Edit the file, then run 'mct apply' to apply settings")
217
+
218
+
219
+ def main():
220
+ app()
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
mct/commands/dock.py ADDED
@@ -0,0 +1,164 @@
1
+ """Dock settings management."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from ..defaults import read, write, restart_app
8
+
9
+ dock_app = typer.Typer()
10
+
11
+ # Valid values for on/off commands
12
+ ON_VALUES = ("on", "true", "1", "yes")
13
+ OFF_VALUES = ("off", "false", "0", "no")
14
+
15
+
16
+ def parse_bool(value: str) -> bool | None:
17
+ """Parse a boolean string value."""
18
+ if value.lower() in ON_VALUES:
19
+ return True
20
+ if value.lower() in OFF_VALUES:
21
+ return False
22
+ return None
23
+
24
+
25
+ @dock_app.command()
26
+ def size(value: Optional[int] = typer.Argument(None, help="Size (32-128)")):
27
+ """Get or set dock icon size."""
28
+ if value is None:
29
+ current = read("com.apple.dock", "tilesize")
30
+ typer.echo(current if current else "64 (default)")
31
+ return
32
+
33
+ if not 32 <= value <= 128:
34
+ typer.echo("Error: size must be between 32 and 128")
35
+ raise typer.Exit(1)
36
+
37
+ write("com.apple.dock", "tilesize", value, "int")
38
+ restart_app("Dock")
39
+ typer.echo(f"Dock size set to {value}")
40
+
41
+
42
+ @dock_app.command()
43
+ def autohide(value: Optional[str] = typer.Argument(None, help="on/off")):
44
+ """Get or set dock auto-hide."""
45
+ if value is None:
46
+ current = read("com.apple.dock", "autohide")
47
+ typer.echo("on" if current else "off")
48
+ return
49
+
50
+ parsed = parse_bool(value)
51
+ if parsed is None:
52
+ typer.echo("Error: use 'on' or 'off'")
53
+ raise typer.Exit(1)
54
+
55
+ write("com.apple.dock", "autohide", parsed, "bool")
56
+ restart_app("Dock")
57
+ typer.echo(f"Dock auto-hide {'enabled' if parsed else 'disabled'}")
58
+
59
+
60
+ @dock_app.command()
61
+ def locked(value: Optional[str] = typer.Argument(None, help="on/off")):
62
+ """Get or set dock size lock."""
63
+ if value is None:
64
+ current = read("com.apple.dock", "size-immutable")
65
+ typer.echo("on" if current else "off")
66
+ return
67
+
68
+ parsed = parse_bool(value)
69
+ if parsed is None:
70
+ typer.echo("Error: use 'on' or 'off'")
71
+ raise typer.Exit(1)
72
+
73
+ write("com.apple.dock", "size-immutable", parsed, "bool")
74
+ restart_app("Dock")
75
+ typer.echo(f"Dock size {'locked' if parsed else 'unlocked'}")
76
+
77
+
78
+ @dock_app.command()
79
+ def magnification(value: Optional[str] = typer.Argument(None, help="on/off")):
80
+ """Get or set dock magnification."""
81
+ if value is None:
82
+ current = read("com.apple.dock", "magnification")
83
+ typer.echo("on" if current else "off")
84
+ return
85
+
86
+ parsed = parse_bool(value)
87
+ if parsed is None:
88
+ typer.echo("Error: use 'on' or 'off'")
89
+ raise typer.Exit(1)
90
+
91
+ write("com.apple.dock", "magnification", parsed, "bool")
92
+ restart_app("Dock")
93
+ typer.echo(f"Dock magnification {'enabled' if parsed else 'disabled'}")
94
+
95
+
96
+ @dock_app.command()
97
+ def recents(value: Optional[str] = typer.Argument(None, help="on/off")):
98
+ """Get or set showing recent apps in dock."""
99
+ if value is None:
100
+ current = read("com.apple.dock", "show-recents")
101
+ typer.echo("on" if current else "off")
102
+ return
103
+
104
+ parsed = parse_bool(value)
105
+ if parsed is None:
106
+ typer.echo("Error: use 'on' or 'off'")
107
+ raise typer.Exit(1)
108
+
109
+ write("com.apple.dock", "show-recents", parsed, "bool")
110
+ restart_app("Dock")
111
+ typer.echo(f"Recent apps {'shown' if parsed else 'hidden'}")
112
+
113
+
114
+ POSITIONS = ("left", "bottom", "right")
115
+
116
+
117
+ @dock_app.command()
118
+ def position(value: Optional[str] = typer.Argument(None, help="left/bottom/right")):
119
+ """Get or set dock position."""
120
+ if value is None:
121
+ current = read("com.apple.dock", "orientation")
122
+ typer.echo(current if current else "bottom")
123
+ return
124
+
125
+ if value.lower() not in POSITIONS:
126
+ typer.echo(f"Error: use {', '.join(POSITIONS)}")
127
+ raise typer.Exit(1)
128
+
129
+ write("com.apple.dock", "orientation", value.lower(), "string")
130
+ restart_app("Dock")
131
+ typer.echo(f"Dock position set to {value.lower()}")
132
+
133
+
134
+ SETTINGS_MAP = {
135
+ "size": ("com.apple.dock", "tilesize", "int", 64),
136
+ "autohide": ("com.apple.dock", "autohide", "bool", False),
137
+ "locked": ("com.apple.dock", "size-immutable", "bool", False),
138
+ "magnification": ("com.apple.dock", "magnification", "bool", False),
139
+ "recents": ("com.apple.dock", "show-recents", "bool", True),
140
+ "position": ("com.apple.dock", "orientation", "string", "bottom"),
141
+ }
142
+
143
+
144
+ @dock_app.command()
145
+ def reset(setting: Optional[str] = typer.Argument(None, help="Setting to reset (or omit for all)")):
146
+ """Reset dock settings to macOS defaults."""
147
+ if setting is None:
148
+ # Reset all
149
+ for name, (domain, key, vtype, default) in SETTINGS_MAP.items():
150
+ write(domain, key, default, vtype)
151
+ typer.echo(f" {name}: reset to {default}")
152
+ restart_app("Dock")
153
+ typer.echo("All dock settings reset")
154
+ return
155
+
156
+ if setting not in SETTINGS_MAP:
157
+ typer.echo(f"Error: unknown setting '{setting}'")
158
+ typer.echo(f"Available: {', '.join(SETTINGS_MAP.keys())}")
159
+ raise typer.Exit(1)
160
+
161
+ domain, key, vtype, default = SETTINGS_MAP[setting]
162
+ write(domain, key, default, vtype)
163
+ restart_app("Dock")
164
+ typer.echo(f"Dock {setting} reset to {default}")
mct/commands/finder.py ADDED
@@ -0,0 +1,153 @@
1
+ """Finder settings management."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from ..defaults import read, write, restart_app
8
+
9
+ finder_app = typer.Typer()
10
+
11
+ ON_VALUES = ("on", "true", "1", "yes")
12
+ OFF_VALUES = ("off", "false", "0", "no")
13
+
14
+
15
+ def parse_bool(value: str) -> bool | None:
16
+ """Parse a boolean string value."""
17
+ if value.lower() in ON_VALUES:
18
+ return True
19
+ if value.lower() in OFF_VALUES:
20
+ return False
21
+ return None
22
+
23
+
24
+ @finder_app.command()
25
+ def extensions(value: Optional[str] = typer.Argument(None, help="on/off")):
26
+ """Get or set showing all file extensions."""
27
+ if value is None:
28
+ current = read("NSGlobalDomain", "AppleShowAllExtensions")
29
+ typer.echo("on" if current else "off")
30
+ return
31
+
32
+ parsed = parse_bool(value)
33
+ if parsed is None:
34
+ typer.echo("Error: use 'on' or 'off'")
35
+ raise typer.Exit(1)
36
+
37
+ write("NSGlobalDomain", "AppleShowAllExtensions", parsed, "bool")
38
+ restart_app("Finder")
39
+ typer.echo(f"File extensions {'shown' if parsed else 'hidden'}")
40
+
41
+
42
+ @finder_app.command()
43
+ def hidden(value: Optional[str] = typer.Argument(None, help="on/off")):
44
+ """Get or set showing hidden files (dotfiles)."""
45
+ if value is None:
46
+ current = read("com.apple.finder", "AppleShowAllFiles")
47
+ typer.echo("on" if current else "off")
48
+ return
49
+
50
+ parsed = parse_bool(value)
51
+ if parsed is None:
52
+ typer.echo("Error: use 'on' or 'off'")
53
+ raise typer.Exit(1)
54
+
55
+ write("com.apple.finder", "AppleShowAllFiles", parsed, "bool")
56
+ restart_app("Finder")
57
+ typer.echo(f"Hidden files {'shown' if parsed else 'hidden'}")
58
+
59
+
60
+ @finder_app.command()
61
+ def pathbar(value: Optional[str] = typer.Argument(None, help="on/off")):
62
+ """Get or set showing path bar at bottom."""
63
+ if value is None:
64
+ current = read("com.apple.finder", "ShowPathbar")
65
+ typer.echo("on" if current else "off")
66
+ return
67
+
68
+ parsed = parse_bool(value)
69
+ if parsed is None:
70
+ typer.echo("Error: use 'on' or 'off'")
71
+ raise typer.Exit(1)
72
+
73
+ write("com.apple.finder", "ShowPathbar", parsed, "bool")
74
+ restart_app("Finder")
75
+ typer.echo(f"Path bar {'shown' if parsed else 'hidden'}")
76
+
77
+
78
+ @finder_app.command()
79
+ def statusbar(value: Optional[str] = typer.Argument(None, help="on/off")):
80
+ """Get or set showing status bar at bottom."""
81
+ if value is None:
82
+ current = read("com.apple.finder", "ShowStatusBar")
83
+ typer.echo("on" if current else "off")
84
+ return
85
+
86
+ parsed = parse_bool(value)
87
+ if parsed is None:
88
+ typer.echo("Error: use 'on' or 'off'")
89
+ raise typer.Exit(1)
90
+
91
+ write("com.apple.finder", "ShowStatusBar", parsed, "bool")
92
+ restart_app("Finder")
93
+ typer.echo(f"Status bar {'shown' if parsed else 'hidden'}")
94
+
95
+
96
+ VIEW_STYLES = {
97
+ "icon": "icnv",
98
+ "list": "Nlsv",
99
+ "column": "clmv",
100
+ "gallery": "glyv",
101
+ }
102
+ VIEW_STYLES_REVERSE = {v: k for k, v in VIEW_STYLES.items()}
103
+
104
+
105
+ @finder_app.command()
106
+ def view(style: Optional[str] = typer.Argument(None, help="icon/list/column/gallery")):
107
+ """Get or set default Finder view style."""
108
+ if style is None:
109
+ current = read("com.apple.finder", "FXPreferredViewStyle")
110
+ name = VIEW_STYLES_REVERSE.get(current, current or "icon")
111
+ typer.echo(name)
112
+ return
113
+
114
+ if style.lower() not in VIEW_STYLES:
115
+ typer.echo(f"Error: use {', '.join(VIEW_STYLES.keys())}")
116
+ raise typer.Exit(1)
117
+
118
+ write("com.apple.finder", "FXPreferredViewStyle", VIEW_STYLES[style.lower()], "string")
119
+ restart_app("Finder")
120
+ typer.echo(f"Default view set to {style.lower()}")
121
+
122
+
123
+ SETTINGS_MAP = {
124
+ "extensions": ("NSGlobalDomain", "AppleShowAllExtensions", "bool", True),
125
+ "hidden": ("com.apple.finder", "AppleShowAllFiles", "bool", False),
126
+ "pathbar": ("com.apple.finder", "ShowPathbar", "bool", False),
127
+ "statusbar": ("com.apple.finder", "ShowStatusBar", "bool", False),
128
+ "view": ("com.apple.finder", "FXPreferredViewStyle", "string", "icnv"),
129
+ }
130
+
131
+
132
+ @finder_app.command()
133
+ def reset(setting: Optional[str] = typer.Argument(None, help="Setting to reset (or omit for all)")):
134
+ """Reset Finder settings to macOS defaults."""
135
+ if setting is None:
136
+ for name, (domain, key, vtype, default) in SETTINGS_MAP.items():
137
+ write(domain, key, default, vtype)
138
+ display = default if vtype != "bool" else ("on" if default else "off")
139
+ typer.echo(f" {name}: reset to {display}")
140
+ restart_app("Finder")
141
+ typer.echo("All Finder settings reset")
142
+ return
143
+
144
+ if setting not in SETTINGS_MAP:
145
+ typer.echo(f"Error: unknown setting '{setting}'")
146
+ typer.echo(f"Available: {', '.join(SETTINGS_MAP.keys())}")
147
+ raise typer.Exit(1)
148
+
149
+ domain, key, vtype, default = SETTINGS_MAP[setting]
150
+ write(domain, key, default, vtype)
151
+ restart_app("Finder")
152
+ display = default if vtype != "bool" else ("on" if default else "off")
153
+ typer.echo(f"Finder {setting} reset to {display}")
@@ -0,0 +1,72 @@
1
+ """Keyboard settings management."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from ..defaults import read, write
8
+
9
+ keyboard_app = typer.Typer()
10
+
11
+ ON_VALUES = ("on", "true", "1", "yes")
12
+ OFF_VALUES = ("off", "false", "0", "no")
13
+
14
+
15
+ def parse_bool(value: str) -> bool | None:
16
+ """Parse a boolean string value."""
17
+ if value.lower() in ON_VALUES:
18
+ return True
19
+ if value.lower() in OFF_VALUES:
20
+ return False
21
+ return None
22
+
23
+
24
+ @keyboard_app.command()
25
+ def repeat(value: Optional[str] = typer.Argument(None, help="on/off")):
26
+ """Get or set key repeat (off = press-and-hold for accents)."""
27
+ if value is None:
28
+ # ApplePressAndHoldEnabled=true means repeat is OFF (accents ON)
29
+ press_hold = read("NSGlobalDomain", "ApplePressAndHoldEnabled")
30
+ typer.echo("off" if press_hold else "on")
31
+ return
32
+
33
+ parsed = parse_bool(value)
34
+ if parsed is None:
35
+ typer.echo("Error: use 'on' or 'off'")
36
+ raise typer.Exit(1)
37
+
38
+ # Invert: repeat on = press-and-hold off
39
+ write("NSGlobalDomain", "ApplePressAndHoldEnabled", not parsed, "bool")
40
+ if parsed:
41
+ typer.echo("Key repeat enabled (press-and-hold for accents disabled)")
42
+ else:
43
+ typer.echo("Key repeat disabled (press-and-hold for accents enabled)")
44
+ typer.echo("Note: restart apps to apply")
45
+
46
+
47
+ SETTINGS_MAP = {
48
+ "repeat": ("NSGlobalDomain", "ApplePressAndHoldEnabled", "bool", True), # True = repeat OFF
49
+ }
50
+
51
+
52
+ @keyboard_app.command()
53
+ def reset(setting: Optional[str] = typer.Argument(None, help="Setting to reset (or omit for all)")):
54
+ """Reset keyboard settings to macOS defaults."""
55
+ if setting is None:
56
+ for name, (domain, key, vtype, default) in SETTINGS_MAP.items():
57
+ write(domain, key, default, vtype)
58
+ # Default is press-and-hold ON (repeat OFF)
59
+ typer.echo(f" {name}: reset to off (press-and-hold enabled)")
60
+ typer.echo("All keyboard settings reset")
61
+ typer.echo("Note: restart apps to apply")
62
+ return
63
+
64
+ if setting not in SETTINGS_MAP:
65
+ typer.echo(f"Error: unknown setting '{setting}'")
66
+ typer.echo(f"Available: {', '.join(SETTINGS_MAP.keys())}")
67
+ raise typer.Exit(1)
68
+
69
+ domain, key, vtype, default = SETTINGS_MAP[setting]
70
+ write(domain, key, default, vtype)
71
+ typer.echo(f"Keyboard {setting} reset to off (press-and-hold enabled)")
72
+ typer.echo("Note: restart apps to apply")