lockin 0.1.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.
- lockin-0.1.0/.gitignore +10 -0
- lockin-0.1.0/.python-version +1 -0
- lockin-0.1.0/PKG-INFO +9 -0
- lockin-0.1.0/README.md +0 -0
- lockin-0.1.0/lockin/__init__.py +3 -0
- lockin-0.1.0/lockin/apps.py +75 -0
- lockin-0.1.0/lockin/blocker.py +128 -0
- lockin-0.1.0/lockin/cli.py +453 -0
- lockin-0.1.0/lockin/config.py +107 -0
- lockin-0.1.0/lockin/daemon.py +185 -0
- lockin-0.1.0/lockin/presets.py +89 -0
- lockin-0.1.0/lockin/session.py +166 -0
- lockin-0.1.0/lockin/ui.py +202 -0
- lockin-0.1.0/main.py +6 -0
- lockin-0.1.0/pyproject.toml +25 -0
- lockin-0.1.0/uv.lock +641 -0
lockin-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
lockin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lockin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI focus blocker for macOS — block distracting websites and apps
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: psutil>=5.9.0
|
|
7
|
+
Requires-Dist: requests>=2.32.5
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: typer>=0.21.1
|
lockin-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""macOS app detection and process killing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
APP_DIRS = [
|
|
11
|
+
Path("/Applications"),
|
|
12
|
+
Path.home() / "Applications",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_installed_apps() -> list[str]:
|
|
17
|
+
"""Scan /Applications and ~/Applications for .app bundles."""
|
|
18
|
+
apps: list[str] = []
|
|
19
|
+
for app_dir in APP_DIRS:
|
|
20
|
+
if not app_dir.exists():
|
|
21
|
+
continue
|
|
22
|
+
for entry in sorted(app_dir.iterdir()):
|
|
23
|
+
if entry.suffix == ".app" and entry.is_dir():
|
|
24
|
+
apps.append(entry.stem)
|
|
25
|
+
return apps
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _quit_app_graceful(app_name: str) -> bool:
|
|
29
|
+
"""Try to quit an app gracefully via osascript."""
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["osascript", "-e", f'quit app "{app_name}"'],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
return result.returncode == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _kill_app_forceful(app_name: str) -> bool:
|
|
39
|
+
"""Forcefully kill an app via killall."""
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["killall", app_name],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
return result.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def kill_app(app_name: str) -> bool:
|
|
49
|
+
"""Kill a running app — try graceful first, then forceful."""
|
|
50
|
+
if _quit_app_graceful(app_name):
|
|
51
|
+
return True
|
|
52
|
+
return _kill_app_forceful(app_name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_app_running(app_name: str) -> bool:
|
|
56
|
+
"""Check if an app is currently running."""
|
|
57
|
+
app_name_lower = app_name.lower()
|
|
58
|
+
for proc in psutil.process_iter(["name"]):
|
|
59
|
+
try:
|
|
60
|
+
proc_name = proc.info["name"]
|
|
61
|
+
if proc_name and app_name_lower in proc_name.lower():
|
|
62
|
+
return True
|
|
63
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
64
|
+
continue
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def kill_blocked_apps(app_names: list[str]) -> list[str]:
|
|
69
|
+
"""Kill all blocked apps that are currently running. Returns list of killed app names."""
|
|
70
|
+
killed: list[str] = []
|
|
71
|
+
for app_name in app_names:
|
|
72
|
+
if is_app_running(app_name):
|
|
73
|
+
if kill_app(app_name):
|
|
74
|
+
killed.append(app_name)
|
|
75
|
+
return killed
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""/etc/hosts manipulation, DNS cache flushing, and chflags protection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HOSTS_FILE = Path("/etc/hosts")
|
|
9
|
+
BLOCK_START = "# >>> LOCKIN BLOCK START >>>"
|
|
10
|
+
BLOCK_END = "# <<< LOCKIN BLOCK END <<<"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(cmd: list[str], check: bool = False) -> subprocess.CompletedProcess:
|
|
14
|
+
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_hosts() -> str:
|
|
18
|
+
return HOSTS_FILE.read_text()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_block_entries(domains: list[str]) -> str:
|
|
22
|
+
"""Generate /etc/hosts block entries."""
|
|
23
|
+
lines = [BLOCK_START]
|
|
24
|
+
for domain in sorted(set(domains)):
|
|
25
|
+
if domain: # skip empty strings
|
|
26
|
+
lines.append(f"0.0.0.0 {domain}")
|
|
27
|
+
lines.append(BLOCK_END)
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_existing_blocks(content: str) -> str:
|
|
32
|
+
"""Remove any existing lockin block section from hosts content."""
|
|
33
|
+
lines = content.splitlines()
|
|
34
|
+
result: list[str] = []
|
|
35
|
+
inside_block = False
|
|
36
|
+
for line in lines:
|
|
37
|
+
if line.strip() == BLOCK_START:
|
|
38
|
+
inside_block = True
|
|
39
|
+
continue
|
|
40
|
+
if line.strip() == BLOCK_END:
|
|
41
|
+
inside_block = False
|
|
42
|
+
continue
|
|
43
|
+
if not inside_block:
|
|
44
|
+
result.append(line)
|
|
45
|
+
# Remove trailing blank lines from our section
|
|
46
|
+
while result and result[-1].strip() == "":
|
|
47
|
+
result.pop()
|
|
48
|
+
return "\n".join(result)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def remove_immutable_flag() -> bool:
|
|
52
|
+
"""Remove the system immutable flag from /etc/hosts."""
|
|
53
|
+
result = _run(["chflags", "noschg", str(HOSTS_FILE)])
|
|
54
|
+
return result.returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_immutable_flag() -> bool:
|
|
58
|
+
"""Set the system immutable flag on /etc/hosts to prevent edits."""
|
|
59
|
+
result = _run(["chflags", "schg", str(HOSTS_FILE)])
|
|
60
|
+
return result.returncode == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def flush_dns_cache() -> None:
|
|
64
|
+
"""Flush the macOS DNS cache."""
|
|
65
|
+
_run(["dscacheutil", "-flushcache"])
|
|
66
|
+
_run(["killall", "-HUP", "mDNSResponder"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_blocks(domains: list[str]) -> bool:
|
|
70
|
+
"""Write domain blocks to /etc/hosts and protect the file.
|
|
71
|
+
|
|
72
|
+
Returns True if blocks were applied successfully.
|
|
73
|
+
"""
|
|
74
|
+
if not domains:
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
remove_immutable_flag()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
current = _read_hosts()
|
|
81
|
+
clean = _strip_existing_blocks(current)
|
|
82
|
+
block_entries = _get_block_entries(domains)
|
|
83
|
+
new_content = clean + "\n\n" + block_entries + "\n"
|
|
84
|
+
HOSTS_FILE.write_text(new_content)
|
|
85
|
+
except PermissionError:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
set_immutable_flag()
|
|
89
|
+
flush_dns_cache()
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_blocks() -> bool:
|
|
94
|
+
"""Remove all lockin blocks from /etc/hosts.
|
|
95
|
+
|
|
96
|
+
Returns True if blocks were removed successfully.
|
|
97
|
+
"""
|
|
98
|
+
remove_immutable_flag()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
current = _read_hosts()
|
|
102
|
+
clean = _strip_existing_blocks(current)
|
|
103
|
+
# Ensure file ends with a newline
|
|
104
|
+
if not clean.endswith("\n"):
|
|
105
|
+
clean += "\n"
|
|
106
|
+
HOSTS_FILE.write_text(clean)
|
|
107
|
+
except PermissionError:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
flush_dns_cache()
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def are_blocks_applied(domains: list[str]) -> bool:
|
|
115
|
+
"""Check if the expected blocks are present in /etc/hosts."""
|
|
116
|
+
if not domains:
|
|
117
|
+
return True
|
|
118
|
+
try:
|
|
119
|
+
content = _read_hosts()
|
|
120
|
+
except PermissionError:
|
|
121
|
+
return False
|
|
122
|
+
return BLOCK_START in content and BLOCK_END in content
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_immutable() -> bool:
|
|
126
|
+
"""Check if /etc/hosts has the system immutable flag set."""
|
|
127
|
+
result = _run(["ls", "-lO", str(HOSTS_FILE)])
|
|
128
|
+
return "schg" in result.stdout
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Typer CLI commands — all user-facing commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from typing_extensions import Annotated
|
|
11
|
+
|
|
12
|
+
from lockin import __version__
|
|
13
|
+
from lockin.config import Config, Profile, Schedule, load_config, save_config
|
|
14
|
+
from lockin.presets import PRESETS, get_preset, list_presets
|
|
15
|
+
from lockin.session import create_session, get_active_session, load_session, SESSION_FILE
|
|
16
|
+
from lockin.apps import list_installed_apps
|
|
17
|
+
from lockin.daemon import install_daemon, uninstall_daemon, is_daemon_installed
|
|
18
|
+
from lockin.blocker import apply_blocks, remove_blocks
|
|
19
|
+
from lockin.ui import (
|
|
20
|
+
console,
|
|
21
|
+
print_error,
|
|
22
|
+
print_info,
|
|
23
|
+
print_success,
|
|
24
|
+
print_warning,
|
|
25
|
+
show_always_blocked,
|
|
26
|
+
show_apps,
|
|
27
|
+
show_presets,
|
|
28
|
+
show_profile_detail,
|
|
29
|
+
show_profiles,
|
|
30
|
+
show_schedules,
|
|
31
|
+
show_status,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="lockin",
|
|
36
|
+
help="CLI focus blocker for macOS — block distracting websites and apps.",
|
|
37
|
+
no_args_is_help=False,
|
|
38
|
+
invoke_without_command=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
profile_app = typer.Typer(help="Manage blocking profiles.")
|
|
42
|
+
schedule_app = typer.Typer(help="Manage auto-start schedules.")
|
|
43
|
+
app.add_typer(profile_app, name="profile")
|
|
44
|
+
app.add_typer(schedule_app, name="schedule")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_duration(duration_str: str) -> int:
|
|
48
|
+
"""Parse duration string like '2h', '30m', '1h30m', '90s' into seconds."""
|
|
49
|
+
pattern = r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?"
|
|
50
|
+
match = re.fullmatch(pattern, duration_str.strip())
|
|
51
|
+
if not match or not any(match.groups()):
|
|
52
|
+
raise typer.BadParameter(
|
|
53
|
+
f"Invalid duration '{duration_str}'. Use format like: 2h, 30m, 1h30m, 90s"
|
|
54
|
+
)
|
|
55
|
+
hours = int(match.group(1) or 0)
|
|
56
|
+
minutes = int(match.group(2) or 0)
|
|
57
|
+
seconds = int(match.group(3) or 0)
|
|
58
|
+
total = hours * 3600 + minutes * 60 + seconds
|
|
59
|
+
if total <= 0:
|
|
60
|
+
raise typer.BadParameter("Duration must be greater than 0.")
|
|
61
|
+
return total
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require_root(action: str = "This action") -> None:
|
|
65
|
+
if os.geteuid() != 0:
|
|
66
|
+
print_error(f"{action} requires root privileges. Run with sudo.")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# -- Main command (status) --
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.callback()
|
|
74
|
+
def main_callback(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
version: Annotated[
|
|
77
|
+
bool, typer.Option("--version", "-v", help="Show version and exit.")
|
|
78
|
+
] = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Lockin — CLI focus blocker for macOS."""
|
|
81
|
+
if version:
|
|
82
|
+
console.print(f"lockin v{__version__}")
|
|
83
|
+
raise typer.Exit()
|
|
84
|
+
# If no subcommand given, show status
|
|
85
|
+
if ctx.invoked_subcommand is None:
|
|
86
|
+
session = load_session()
|
|
87
|
+
if session and session.verify() and not session.is_expired:
|
|
88
|
+
show_status(session)
|
|
89
|
+
else:
|
|
90
|
+
show_status(None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def status() -> None:
|
|
95
|
+
"""Show current session status."""
|
|
96
|
+
session = load_session()
|
|
97
|
+
if session and session.verify() and not session.is_expired:
|
|
98
|
+
show_status(session)
|
|
99
|
+
else:
|
|
100
|
+
show_status(None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- Start / Stop --
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def start(
|
|
108
|
+
profile_name: Annotated[str, typer.Argument(help="Profile name to activate.")],
|
|
109
|
+
duration: Annotated[
|
|
110
|
+
str, typer.Option("--duration", "-d", help="Session duration (e.g. 2h, 30m, 1h30m).")
|
|
111
|
+
] = "1h",
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Start a focus session."""
|
|
114
|
+
_require_root("Starting a focus session")
|
|
115
|
+
|
|
116
|
+
# Check for existing active session
|
|
117
|
+
active = get_active_session()
|
|
118
|
+
if active:
|
|
119
|
+
print_error(
|
|
120
|
+
f"A session is already active (profile: {active.profile_name}, "
|
|
121
|
+
f"remaining: {int(active.remaining_seconds)}s). Cannot start another."
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
config = load_config()
|
|
126
|
+
profile = config.profiles.get(profile_name)
|
|
127
|
+
if profile is None:
|
|
128
|
+
print_error(f"Profile '{profile_name}' not found. Create one with: lockin profile create {profile_name} --preset social")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
duration_seconds = _parse_duration(duration)
|
|
132
|
+
blocked_domains = profile.resolve_domains()
|
|
133
|
+
blocked_apps = profile.resolve_apps()
|
|
134
|
+
|
|
135
|
+
# Add always-blocked items
|
|
136
|
+
from lockin.presets import SUBDOMAIN_PREFIXES
|
|
137
|
+
|
|
138
|
+
for site in config.always_blocked.sites:
|
|
139
|
+
for prefix in SUBDOMAIN_PREFIXES:
|
|
140
|
+
d = f"{prefix}{site}"
|
|
141
|
+
if d not in blocked_domains:
|
|
142
|
+
blocked_domains.append(d)
|
|
143
|
+
for a in config.always_blocked.apps:
|
|
144
|
+
if a not in blocked_apps:
|
|
145
|
+
blocked_apps.append(a)
|
|
146
|
+
|
|
147
|
+
if not blocked_domains and not blocked_apps:
|
|
148
|
+
print_warning("This profile has nothing to block. Add presets or custom sites first.")
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
|
|
151
|
+
# Check if daemon is installed
|
|
152
|
+
if not is_daemon_installed():
|
|
153
|
+
print_warning("Watchdog daemon is not installed. Installing now...")
|
|
154
|
+
if not install_daemon():
|
|
155
|
+
print_error("Failed to install watchdog daemon.")
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
print_success("Watchdog daemon installed.")
|
|
158
|
+
|
|
159
|
+
# Apply blocks
|
|
160
|
+
if blocked_domains:
|
|
161
|
+
if not apply_blocks(blocked_domains):
|
|
162
|
+
print_error("Failed to apply website blocks. Are you running as root?")
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
|
|
165
|
+
# Kill blocked apps immediately
|
|
166
|
+
from lockin.apps import kill_blocked_apps
|
|
167
|
+
|
|
168
|
+
killed = kill_blocked_apps(blocked_apps)
|
|
169
|
+
if killed:
|
|
170
|
+
print_info(f"Killed blocked apps: {', '.join(killed)}")
|
|
171
|
+
|
|
172
|
+
# Create signed session
|
|
173
|
+
sess = create_session(
|
|
174
|
+
profile_name=profile_name,
|
|
175
|
+
duration_seconds=duration_seconds,
|
|
176
|
+
blocked_domains=blocked_domains,
|
|
177
|
+
blocked_apps=blocked_apps,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
from lockin.ui import format_duration
|
|
181
|
+
|
|
182
|
+
print_success(f"Focus session started!")
|
|
183
|
+
print_info(f"Profile: {profile_name}")
|
|
184
|
+
print_info(f"Duration: {format_duration(duration_seconds)}")
|
|
185
|
+
print_info(f"Blocking: {len(blocked_domains)} domains, {len(blocked_apps)} apps")
|
|
186
|
+
print_warning("This session cannot be stopped until the timer expires.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def stop(
|
|
191
|
+
force: Annotated[
|
|
192
|
+
bool, typer.Option("--force", "-f", help="Force stop (NOT ALLOWED during active sessions).")
|
|
193
|
+
] = False,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Stop an active session. REFUSED during active sessions — this is by design."""
|
|
196
|
+
session = load_session()
|
|
197
|
+
|
|
198
|
+
if session is None:
|
|
199
|
+
print_info("No active session to stop.")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if session.verify() and not session.is_expired:
|
|
203
|
+
# Active valid session — refuse to stop
|
|
204
|
+
from lockin.ui import format_duration
|
|
205
|
+
|
|
206
|
+
remaining = session.remaining_seconds
|
|
207
|
+
print_error("Cannot stop an active focus session. This is by design.")
|
|
208
|
+
print_error(f"Remaining: {format_duration(remaining)}")
|
|
209
|
+
print_warning("The session will end automatically when the timer expires.")
|
|
210
|
+
if force:
|
|
211
|
+
print_error("Even --force cannot stop an active session. Stay focused!")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
# Session is expired or invalid — clean up
|
|
215
|
+
print_info("Session has expired. Cleaning up...")
|
|
216
|
+
remove_blocks()
|
|
217
|
+
from lockin.session import delete_session
|
|
218
|
+
|
|
219
|
+
delete_session()
|
|
220
|
+
print_success("Session cleaned up.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- Presets --
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("preset")
|
|
227
|
+
def preset_list() -> None:
|
|
228
|
+
"""Show built-in presets."""
|
|
229
|
+
show_presets(list_presets())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# -- Profile management --
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@profile_app.command("list")
|
|
236
|
+
def profile_list() -> None:
|
|
237
|
+
"""List all profiles."""
|
|
238
|
+
config = load_config()
|
|
239
|
+
show_profiles(config.profiles)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@profile_app.command("create")
|
|
243
|
+
def profile_create(
|
|
244
|
+
name: Annotated[str, typer.Argument(help="Profile name.")],
|
|
245
|
+
preset: Annotated[
|
|
246
|
+
Optional[list[str]],
|
|
247
|
+
typer.Option("--preset", "-p", help="Preset category to include (can repeat)."),
|
|
248
|
+
] = None,
|
|
249
|
+
site: Annotated[
|
|
250
|
+
Optional[list[str]],
|
|
251
|
+
typer.Option("--site", "-s", help="Custom domain to block (can repeat)."),
|
|
252
|
+
] = None,
|
|
253
|
+
block_app: Annotated[
|
|
254
|
+
Optional[list[str]],
|
|
255
|
+
typer.Option("--app", "-a", help="App name to block (can repeat)."),
|
|
256
|
+
] = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Create a new blocking profile."""
|
|
259
|
+
config = load_config()
|
|
260
|
+
|
|
261
|
+
if name in config.profiles:
|
|
262
|
+
print_error(f"Profile '{name}' already exists. Delete it first or choose another name.")
|
|
263
|
+
raise typer.Exit(1)
|
|
264
|
+
|
|
265
|
+
presets = preset or []
|
|
266
|
+
for p in presets:
|
|
267
|
+
if p not in PRESETS:
|
|
268
|
+
print_error(f"Unknown preset '{p}'. Available: {', '.join(PRESETS.keys())}")
|
|
269
|
+
raise typer.Exit(1)
|
|
270
|
+
|
|
271
|
+
profile = Profile(
|
|
272
|
+
name=name,
|
|
273
|
+
presets=presets,
|
|
274
|
+
custom_sites=site or [],
|
|
275
|
+
blocked_apps=block_app or [],
|
|
276
|
+
)
|
|
277
|
+
config.profiles[name] = profile
|
|
278
|
+
save_config(config)
|
|
279
|
+
|
|
280
|
+
print_success(f"Profile '{name}' created.")
|
|
281
|
+
show_profile_detail(profile)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@profile_app.command("show")
|
|
285
|
+
def profile_show(
|
|
286
|
+
name: Annotated[str, typer.Argument(help="Profile name to show.")],
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Show details of a profile."""
|
|
289
|
+
config = load_config()
|
|
290
|
+
profile = config.profiles.get(name)
|
|
291
|
+
if profile is None:
|
|
292
|
+
print_error(f"Profile '{name}' not found.")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
show_profile_detail(profile)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@profile_app.command("delete")
|
|
298
|
+
def profile_delete(
|
|
299
|
+
name: Annotated[str, typer.Argument(help="Profile name to delete.")],
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Delete a profile."""
|
|
302
|
+
config = load_config()
|
|
303
|
+
if name not in config.profiles:
|
|
304
|
+
print_error(f"Profile '{name}' not found.")
|
|
305
|
+
raise typer.Exit(1)
|
|
306
|
+
del config.profiles[name]
|
|
307
|
+
save_config(config)
|
|
308
|
+
print_success(f"Profile '{name}' deleted.")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# -- Always-blocked --
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("block")
|
|
315
|
+
def block_domain(
|
|
316
|
+
domain: Annotated[str, typer.Argument(help="Domain to always block.")],
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Add a domain to the always-blocked list."""
|
|
319
|
+
config = load_config()
|
|
320
|
+
if domain in config.always_blocked.sites:
|
|
321
|
+
print_warning(f"'{domain}' is already in the always-blocked list.")
|
|
322
|
+
return
|
|
323
|
+
config.always_blocked.sites.append(domain)
|
|
324
|
+
save_config(config)
|
|
325
|
+
print_success(f"Added '{domain}' to always-blocked list.")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.command("unblock")
|
|
329
|
+
def unblock_domain(
|
|
330
|
+
domain: Annotated[str, typer.Argument(help="Domain to remove from always-blocked.")],
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Remove a domain from the always-blocked list."""
|
|
333
|
+
config = load_config()
|
|
334
|
+
if domain not in config.always_blocked.sites:
|
|
335
|
+
print_error(f"'{domain}' is not in the always-blocked list.")
|
|
336
|
+
raise typer.Exit(1)
|
|
337
|
+
config.always_blocked.sites.remove(domain)
|
|
338
|
+
save_config(config)
|
|
339
|
+
print_success(f"Removed '{domain}' from always-blocked list.")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# -- Schedules --
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@schedule_app.command("list")
|
|
346
|
+
def schedule_list() -> None:
|
|
347
|
+
"""List all schedules."""
|
|
348
|
+
config = load_config()
|
|
349
|
+
show_schedules(config.schedules)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@schedule_app.command("create")
|
|
353
|
+
def schedule_create(
|
|
354
|
+
name: Annotated[str, typer.Argument(help="Schedule name.")],
|
|
355
|
+
profile_name: Annotated[str, typer.Option("--profile", "-p", help="Profile to activate.")],
|
|
356
|
+
days: Annotated[str, typer.Option("--days", "-D", help="Comma-separated days (mon,tue,wed,thu,fri,sat,sun).")],
|
|
357
|
+
start_time: Annotated[str, typer.Option("--start", "-s", help="Start time (HH:MM).")] = "09:00",
|
|
358
|
+
duration_minutes: Annotated[int, typer.Option("--duration", "-d", help="Duration in minutes.")] = 120,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Create an auto-start schedule."""
|
|
361
|
+
config = load_config()
|
|
362
|
+
|
|
363
|
+
if name in config.schedules:
|
|
364
|
+
print_error(f"Schedule '{name}' already exists.")
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
|
|
367
|
+
if profile_name not in config.profiles:
|
|
368
|
+
print_error(f"Profile '{profile_name}' not found. Create it first.")
|
|
369
|
+
raise typer.Exit(1)
|
|
370
|
+
|
|
371
|
+
day_map = {
|
|
372
|
+
"mon": "Monday", "tue": "Tuesday", "wed": "Wednesday",
|
|
373
|
+
"thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday",
|
|
374
|
+
}
|
|
375
|
+
day_list: list[str] = []
|
|
376
|
+
for d in days.split(","):
|
|
377
|
+
d = d.strip().lower()
|
|
378
|
+
if d not in day_map:
|
|
379
|
+
print_error(f"Invalid day '{d}'. Use: mon,tue,wed,thu,fri,sat,sun")
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
day_list.append(day_map[d])
|
|
382
|
+
|
|
383
|
+
schedule = Schedule(
|
|
384
|
+
name=name,
|
|
385
|
+
profile=profile_name,
|
|
386
|
+
days=day_list,
|
|
387
|
+
start_time=start_time,
|
|
388
|
+
duration_minutes=duration_minutes,
|
|
389
|
+
)
|
|
390
|
+
config.schedules[name] = schedule
|
|
391
|
+
save_config(config)
|
|
392
|
+
print_success(f"Schedule '{name}' created.")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@schedule_app.command("delete")
|
|
396
|
+
def schedule_delete(
|
|
397
|
+
name: Annotated[str, typer.Argument(help="Schedule name to delete.")],
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Delete a schedule."""
|
|
400
|
+
config = load_config()
|
|
401
|
+
if name not in config.schedules:
|
|
402
|
+
print_error(f"Schedule '{name}' not found.")
|
|
403
|
+
raise typer.Exit(1)
|
|
404
|
+
del config.schedules[name]
|
|
405
|
+
save_config(config)
|
|
406
|
+
print_success(f"Schedule '{name}' deleted.")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# -- Apps --
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.command("apps")
|
|
413
|
+
def apps_list() -> None:
|
|
414
|
+
"""List detected macOS apps."""
|
|
415
|
+
installed = list_installed_apps()
|
|
416
|
+
show_apps(installed)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# -- Daemon install --
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.command("install")
|
|
423
|
+
def install_cmd() -> None:
|
|
424
|
+
"""Install the launchd watchdog daemon."""
|
|
425
|
+
_require_root("Installing the daemon")
|
|
426
|
+
if install_daemon():
|
|
427
|
+
print_success("Watchdog daemon installed and loaded.")
|
|
428
|
+
print_info("The daemon will enforce focus sessions and prevent tampering.")
|
|
429
|
+
else:
|
|
430
|
+
print_error("Failed to install the watchdog daemon.")
|
|
431
|
+
raise typer.Exit(1)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.command("uninstall")
|
|
435
|
+
def uninstall_cmd() -> None:
|
|
436
|
+
"""Uninstall the launchd watchdog daemon."""
|
|
437
|
+
_require_root("Uninstalling the daemon")
|
|
438
|
+
|
|
439
|
+
# Refuse if there's an active session
|
|
440
|
+
active = get_active_session()
|
|
441
|
+
if active:
|
|
442
|
+
print_error("Cannot uninstall while a focus session is active.")
|
|
443
|
+
raise typer.Exit(1)
|
|
444
|
+
|
|
445
|
+
if uninstall_daemon():
|
|
446
|
+
print_success("Watchdog daemon uninstalled.")
|
|
447
|
+
else:
|
|
448
|
+
print_error("Failed to uninstall the watchdog daemon.")
|
|
449
|
+
raise typer.Exit(1)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main() -> None:
|
|
453
|
+
app()
|