safaai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. safaai/__init__.py +3 -0
  2. safaai/__main__.py +22 -0
  3. safaai/actions/__init__.py +1 -0
  4. safaai/actions/executor.py +201 -0
  5. safaai/actions/kill.py +33 -0
  6. safaai/actions/optimize.py +85 -0
  7. safaai/actions/trash.py +53 -0
  8. safaai/app.py +59 -0
  9. safaai/commands/__init__.py +1 -0
  10. safaai/commands/palette.py +317 -0
  11. safaai/config.py +36 -0
  12. safaai/core/__init__.py +30 -0
  13. safaai/core/debug_log.py +86 -0
  14. safaai/core/ignore_db.py +108 -0
  15. safaai/core/journal.py +164 -0
  16. safaai/core/log_db.py +203 -0
  17. safaai/core/updater.py +101 -0
  18. safaai/css/__init__.py +6 -0
  19. safaai/css/dashboard.py +96 -0
  20. safaai/css/scan_results.py +54 -0
  21. safaai/css/screens.py +23 -0
  22. safaai/css/splash.py +28 -0
  23. safaai/icon.txt +55 -0
  24. safaai/scanners/__init__.py +3 -0
  25. safaai/scanners/base.py +51 -0
  26. safaai/scanners/brew.py +172 -0
  27. safaai/scanners/disk.py +117 -0
  28. safaai/scanners/docker.py +197 -0
  29. safaai/scanners/graph.py +624 -0
  30. safaai/scanners/memory.py +84 -0
  31. safaai/scanners/node.py +167 -0
  32. safaai/scanners/project_purge.py +111 -0
  33. safaai/scanners/registry.py +37 -0
  34. safaai/scanners/startup.py +112 -0
  35. safaai/scanners/xcode.py +80 -0
  36. safaai/screens/__init__.py +42 -0
  37. safaai/screens/admin_password.py +124 -0
  38. safaai/screens/clean_result.py +120 -0
  39. safaai/screens/confirm.py +631 -0
  40. safaai/screens/dashboard.py +38 -0
  41. safaai/screens/ignore_screen.py +105 -0
  42. safaai/screens/log_screen.py +462 -0
  43. safaai/screens/optimize_confirm.py +85 -0
  44. safaai/screens/progress.py +40 -0
  45. safaai/screens/scan_results.py +94 -0
  46. safaai/screens/splash.py +30 -0
  47. safaai/screens/splash_asciimatics.py +99 -0
  48. safaai/screens/sysinfo.py +389 -0
  49. safaai/screens/update_confirm.py +101 -0
  50. safaai/widgets/broom_audit.py +55 -0
  51. safaai/widgets/confirm_row.py +342 -0
  52. safaai/widgets/disk_bento.py +77 -0
  53. safaai/widgets/health_gauge.py +44 -0
  54. safaai/widgets/metrics.py +115 -0
  55. safaai/widgets/suggestions.py +47 -0
  56. safaai/workflows/__init__.py +1 -0
  57. safaai/workflows/engine.py +152 -0
  58. safaai-0.1.0.dist-info/METADATA +118 -0
  59. safaai-0.1.0.dist-info/RECORD +61 -0
  60. safaai-0.1.0.dist-info/WHEEL +4 -0
  61. safaai-0.1.0.dist-info/entry_points.txt +2 -0
safaai/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """safaai — TUI-first developer workstation optimizer."""
2
+
3
+ __version__ = "0.1.0"
safaai/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ import sys
2
+ from safaai.app import SafaaiApp
3
+ from safaai.screens.splash_asciimatics import run_splash_screen
4
+ from safaai.screens.splash import SplashScreen
5
+
6
+
7
+ def main():
8
+ try:
9
+ run_splash_screen()
10
+ SplashScreen.skip_delay = True
11
+ except KeyboardInterrupt:
12
+ sys.exit(0)
13
+ except Exception:
14
+ pass
15
+
16
+ app = SafaaiApp()
17
+ app.run()
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
22
+
@@ -0,0 +1 @@
1
+ """Action executors — safe, previewable, trash-aware operations."""
@@ -0,0 +1,201 @@
1
+ """Execute cleanup commands safely with timeout and reporting."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import shlex
6
+
7
+ from safaai.core.debug_log import get_logger
8
+
9
+ _admin_session_active: bool = False
10
+ _session_sudo_password: str | None = None
11
+
12
+
13
+ def set_session_sudo_password(pwd: str | None) -> None:
14
+ """Set in-memory session sudo password (never stored on disk)."""
15
+ global _session_sudo_password, _admin_session_active
16
+ _session_sudo_password = pwd
17
+ if pwd is not None:
18
+ _admin_session_active = True
19
+
20
+
21
+ def get_session_sudo_password() -> str | None:
22
+ """Get in-memory session sudo password."""
23
+ return _session_sudo_password
24
+
25
+
26
+ async def verify_sudo_password(pwd: str) -> bool:
27
+ """Verify sudo password via sudo -S -v."""
28
+ try:
29
+ proc = await asyncio.create_subprocess_exec(
30
+ "sudo", "-S", "-v",
31
+ stdin=asyncio.subprocess.PIPE,
32
+ stdout=asyncio.subprocess.DEVNULL,
33
+ stderr=asyncio.subprocess.DEVNULL,
34
+ )
35
+ await proc.communicate(input=f"{pwd}\n".encode())
36
+ return proc.returncode == 0
37
+ except (OSError, asyncio.CancelledError):
38
+ return False
39
+
40
+
41
+ async def request_admin_privileges() -> bool:
42
+ """Request admin privileges via macOS native dialog.
43
+
44
+ Calls ``sudo -v`` through ``osascript`` so the user gets the standard
45
+ macOS password dialog instead of a hidden terminal prompt. Once
46
+ authenticated the credential stays cached for the default sudo timeout
47
+ (usually 5 minutes).
48
+
49
+ Returns True if the user authenticated, False if they cancelled.
50
+ """
51
+ global _admin_session_active
52
+ if _session_sudo_password is not None:
53
+ return True
54
+ try:
55
+ proc = await asyncio.create_subprocess_exec(
56
+ "osascript", "-e",
57
+ 'do shell script "sudo -v" with administrator privileges',
58
+ stdout=asyncio.subprocess.DEVNULL,
59
+ stderr=asyncio.subprocess.DEVNULL,
60
+ )
61
+ await proc.communicate()
62
+ _admin_session_active = proc.returncode == 0
63
+ except (OSError, asyncio.CancelledError):
64
+ _admin_session_active = False
65
+ return _admin_session_active
66
+
67
+
68
+ def admin_session_active() -> bool:
69
+ """Whether an admin credential is cached for the current session."""
70
+ return bool(_session_sudo_password) or _admin_session_active
71
+
72
+
73
+ async def run_command(cmd: str, timeout: int = 30, sudo_pwd: str | None = None) -> dict:
74
+ """Run a single shell command with timeout.
75
+
76
+ If sudo_pwd is provided (or session password is set), runs with elevated
77
+ sudo privileges via sudo -S.
78
+ Returns {"command": str, "exit_code": int, "stdout": str, "stderr": str}.
79
+ """
80
+ logr = get_logger()
81
+ pwd = sudo_pwd or _session_sudo_password
82
+ logr.debug(" run_command: %s (timeout=%ds, elevated=%s)", cmd, timeout, bool(pwd),
83
+ extra={"command": cmd, "timeout": timeout})
84
+
85
+ if pwd and (cmd.startswith("sudo ") or "sudo " in cmd):
86
+ # Convert 'sudo ...' to 'sudo -S ...' for stdin password feeding
87
+ if cmd.startswith("sudo ") and not cmd.startswith("sudo -S "):
88
+ cmd = "sudo -S " + cmd[5:]
89
+ try:
90
+ proc = await asyncio.create_subprocess_shell(
91
+ cmd,
92
+ stdin=asyncio.subprocess.PIPE,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ )
96
+ stdout, stderr = await asyncio.wait_for(
97
+ proc.communicate(input=f"{pwd}\n".encode()), timeout=timeout
98
+ )
99
+ return {
100
+ "command": cmd,
101
+ "exit_code": proc.returncode or 0,
102
+ "stdout": stdout.decode().strip(),
103
+ "stderr": stderr.decode().strip(),
104
+ }
105
+ except asyncio.TimeoutError:
106
+ return {
107
+ "command": cmd,
108
+ "exit_code": -1,
109
+ "stdout": "",
110
+ "stderr": f"Timed out after {timeout}s",
111
+ }
112
+ except OSError as e:
113
+ return {"command": cmd, "exit_code": -1, "stdout": "", "stderr": str(e)}
114
+
115
+ # When no password is supplied, always use non-interactive 'sudo -n'
116
+ # so sudo never touches /dev/tty or corrupts terminal line settings.
117
+ if not pwd and cmd.startswith("sudo ") and not cmd.startswith("sudo -n "):
118
+ cmd = "sudo -n " + cmd[5:]
119
+
120
+ try:
121
+ proc = await asyncio.create_subprocess_shell(
122
+ cmd,
123
+ stdout=asyncio.subprocess.PIPE,
124
+ stderr=asyncio.subprocess.PIPE,
125
+ )
126
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
127
+ return {
128
+ "command": cmd,
129
+ "exit_code": proc.returncode or 0,
130
+ "stdout": stdout.decode().strip(),
131
+ "stderr": stderr.decode().strip(),
132
+ }
133
+ except asyncio.TimeoutError:
134
+ return {
135
+ "command": cmd,
136
+ "exit_code": -1,
137
+ "stdout": "",
138
+ "stderr": f"Timed out after {timeout}s",
139
+ }
140
+ except OSError as e:
141
+ return {"command": cmd, "exit_code": -1, "stdout": "", "stderr": str(e)}
142
+
143
+
144
+ async def run_commands(commands: list[str], timeout: int = 30, sudo_pwd: str | None = None) -> list[dict]:
145
+ """Run multiple commands sequentially and return results.
146
+
147
+ Commands are run one at a time (not parallel) so output stays ordered.
148
+ """
149
+ results: list[dict] = []
150
+ for cmd in commands:
151
+ result = await run_command(cmd, timeout=timeout, sudo_pwd=sudo_pwd)
152
+ results.append(result)
153
+ return results
154
+
155
+
156
+ async def force_remove_path(path: str, timeout: int = 30, sudo_pwd: str | None = None) -> dict:
157
+ """Robust force deletion of files/directories.
158
+
159
+ 1. Unlocks flags (chflags -R nouchg).
160
+ 2. Grants write permissions (chmod -R u+rwX).
161
+ 3. Runs rm -rf.
162
+ 4. Evaluates if elevated privileges or quarantine fallback are needed.
163
+ """
164
+ from pathlib import Path
165
+
166
+ pwd = sudo_pwd or _session_sudo_password
167
+ if "*" in path:
168
+ shlex_target = path
169
+ else:
170
+ target = Path(path)
171
+ if not target.exists():
172
+ return {"command": f"rm -rf {path}", "exit_code": 0, "stdout": "", "stderr": "Already deleted"}
173
+ shlex_target = shlex.quote(str(target))
174
+
175
+ await run_command(f"chflags -R nouchg {shlex_target} 2>/dev/null", timeout=10)
176
+ await run_command(f"chmod -R u+rwX {shlex_target} 2>/dev/null", timeout=10)
177
+
178
+ if pwd:
179
+ await run_command(f"sudo -S chflags -R nouchg {shlex_target} 2>/dev/null", timeout=10, sudo_pwd=pwd)
180
+ await run_command(f"sudo -S chmod -R u+rwX {shlex_target} 2>/dev/null", timeout=10, sudo_pwd=pwd)
181
+ res = await run_command(f"sudo -S rm -rf {shlex_target}", timeout=timeout, sudo_pwd=pwd)
182
+ else:
183
+ res = await run_command(f"rm -rf {shlex_target}", timeout=timeout)
184
+
185
+ if "*" not in path and not Path(path).exists():
186
+ return res
187
+
188
+ if res.get("exit_code", 0) == 0:
189
+ return res
190
+
191
+ if "*" not in path and Path(path).exists():
192
+ from safaai.actions.trash import move_to_trash
193
+ status = await move_to_trash(Path(path))
194
+ return {
195
+ "command": f"quarantine {path}",
196
+ "exit_code": 0 if not Path(path).exists() else 1,
197
+ "stdout": status,
198
+ "stderr": "" if not Path(path).exists() else "Quarantine fallback completed",
199
+ }
200
+
201
+ return res
safaai/actions/kill.py ADDED
@@ -0,0 +1,33 @@
1
+ """Kill user-owned processes safely."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+
7
+ import psutil
8
+
9
+
10
+ async def kill_process(pid: int, force: bool = False) -> str:
11
+ """Send SIGTERM to a process. Returns status message.
12
+
13
+ When *force* is True, falls back to SIGKILL after 3s timeout.
14
+ """
15
+ try:
16
+ proc = psutil.Process(pid)
17
+ name = proc.name() or str(pid)
18
+ except psutil.NoSuchProcess:
19
+ return f"Process {pid} already gone."
20
+
21
+ try:
22
+ if force:
23
+ proc.kill()
24
+ return f"Killed {name} (PID {pid})."
25
+ proc.terminate()
26
+ try:
27
+ proc.wait(timeout=3)
28
+ return f"Terminated {name} (PID {pid})."
29
+ except psutil.TimeoutExpired:
30
+ proc.kill()
31
+ return f"Terminated {name} (PID {pid}) — force killed after timeout."
32
+ except psutil.AccessDenied:
33
+ return f"Access denied — cannot kill PID {pid} ({name})."
@@ -0,0 +1,85 @@
1
+ """macOS optimization actions — kill user apps + purge RAM."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+
7
+ import psutil
8
+
9
+ from safaai.actions.executor import (
10
+ admin_session_active,
11
+ get_session_sudo_password,
12
+ request_admin_privileges,
13
+ run_command,
14
+ )
15
+
16
+
17
+ def _parent_chain(pid: int) -> set[int]:
18
+ """Return set of PIDs from *pid* up to launchd (PID 1)."""
19
+ pids = {pid, 1}
20
+ try:
21
+ proc = psutil.Process(pid)
22
+ while True:
23
+ ppid = proc.ppid()
24
+ if ppid <= 1:
25
+ break
26
+ pids.add(ppid)
27
+ proc = psutil.Process(ppid)
28
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
29
+ pass
30
+ return pids
31
+
32
+
33
+ async def kill_user_apps() -> dict:
34
+ """Kill all third-party user processes, keeping only system + safaai.
35
+
36
+ System = root-owned + /System/ binaries + launchd.
37
+ Safaai's own PID and its terminal/SSH parent chain are also protected.
38
+ """
39
+ my_pid = os.getpid()
40
+ protected_pids = _parent_chain(my_pid)
41
+ protected_pids.add(my_pid)
42
+
43
+ targets: list[psutil.Process] = []
44
+
45
+ for proc in psutil.process_iter(["pid", "name", "username", "exe"]):
46
+ try:
47
+ pid = proc.info["pid"]
48
+ if pid in protected_pids:
49
+ continue
50
+ if proc.info.get("username") == "root":
51
+ continue
52
+ exe = (proc.info.get("exe") or "")
53
+ if exe.startswith("/System/") or exe.startswith("/usr/libexec/"):
54
+ continue
55
+ targets.append(proc)
56
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
57
+ continue
58
+
59
+ killed_names: list[str] = []
60
+ for p in targets:
61
+ try:
62
+ p.terminate()
63
+ killed_names.append(p.name() or str(p.pid))
64
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
65
+ pass
66
+
67
+ # Grace period then force-kill survivors
68
+ gone, alive = psutil.wait_procs(targets, timeout=3)
69
+ for p in alive:
70
+ try:
71
+ p.kill()
72
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
73
+ pass
74
+
75
+ return {"exit_code": 0, "killed": len(killed_names), "stdout": f"Killed {len(killed_names)} apps"}
76
+
77
+
78
+ async def purge_memory() -> dict:
79
+ """Run macOS ``purge`` to release inactive memory pages (needs admin)."""
80
+ if not admin_session_active():
81
+ ok = await request_admin_privileges()
82
+ if not ok:
83
+ return {"command": "purge", "exit_code": -1, "stdout": "", "stderr": "Admin access denied"}
84
+ sudo_pwd = get_session_sudo_password()
85
+ return await run_command("sudo purge", sudo_pwd=sudo_pwd)
@@ -0,0 +1,53 @@
1
+ """Move paths to system Trash or Quarantine instead of risky delete."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import shlex
6
+ import shutil
7
+ import time
8
+ from pathlib import Path
9
+
10
+
11
+ async def move_to_trash(path: str | Path) -> str:
12
+ """Move a file or directory to macOS Trash via osascript.
13
+
14
+ Falls back to ~/.Trash and then ~/.local/share/safaai/quarantine/
15
+ Returns a status message.
16
+ """
17
+ path = Path(path)
18
+ if not path.exists():
19
+ return f"Path does not exist: {path}"
20
+
21
+ try:
22
+ escaped = shlex.quote(str(path.resolve()))
23
+ script = f'tell app "Finder" to delete POSIX file {escaped}'
24
+ proc = await asyncio.create_subprocess_exec(
25
+ "osascript", "-e", script,
26
+ stdout=asyncio.subprocess.PIPE,
27
+ stderr=asyncio.subprocess.PIPE,
28
+ )
29
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
30
+ if proc.returncode == 0:
31
+ return f"Moved to Trash: {path}"
32
+ except (OSError, asyncio.TimeoutError):
33
+ pass
34
+
35
+ # Fallback 1: move to ~/.Trash/
36
+ trash_dir = Path.home() / ".Trash"
37
+ if trash_dir.exists():
38
+ trash_target = trash_dir / f"{path.name}_{int(time.time())}"
39
+ try:
40
+ await asyncio.to_thread(shutil.move, str(path), str(trash_target))
41
+ return f"Moved to Trash (fallback): {path}"
42
+ except OSError:
43
+ pass
44
+
45
+ # Fallback 2: Safe Quarantine directory
46
+ quarantine_dir = Path.home() / ".local" / "share" / "safaai" / "quarantine"
47
+ quarantine_dir.mkdir(parents=True, exist_ok=True)
48
+ quarantine_target = quarantine_dir / f"{int(time.time())}_{path.name}"
49
+ try:
50
+ await asyncio.to_thread(shutil.move, str(path), str(quarantine_target))
51
+ return f"Quarantined safely: {path} → {quarantine_target}"
52
+ except OSError as e:
53
+ return f"Failed to trash or quarantine {path}: {e}"
safaai/app.py ADDED
@@ -0,0 +1,59 @@
1
+ """SafaaiApp — root application."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from textual.app import App
7
+ from textual.binding import Binding
8
+
9
+ from safaai.commands.palette import SafaaiCommandProvider
10
+ from safaai.css import APP_CSS
11
+ from safaai.scanners.brew import BrewScanner
12
+ from safaai.scanners.disk import DiskScanner
13
+ from safaai.scanners.docker import DockerScanner
14
+ from safaai.scanners.graph import GraphScanner
15
+ from safaai.scanners.memory import MemoryScanner
16
+ from safaai.scanners.node import NodeScanner
17
+ from safaai.scanners.project_purge import ProjectPurgeScanner
18
+ from safaai.scanners.registry import get_registry
19
+ from safaai.scanners.startup import StartupScanner
20
+ from safaai.scanners.xcode import XcodeScanner
21
+ from safaai.screens.dashboard import DashboardScreen
22
+ from safaai.screens.splash import SplashScreen
23
+
24
+
25
+ class SafaaiApp(App):
26
+ """Safaai TUI application."""
27
+
28
+ TITLE = "safaai"
29
+ CSS = APP_CSS
30
+ SCREENS = {
31
+ "dashboard": DashboardScreen,
32
+ "splash": SplashScreen,
33
+ }
34
+ COMMANDS = {SafaaiCommandProvider}
35
+
36
+ BINDINGS = [
37
+ Binding("q", "quit", "Quit", priority=True),
38
+ ]
39
+
40
+ def on_mount(self) -> None:
41
+ # Register built-in scanners
42
+ get_registry().register(DiskScanner())
43
+ get_registry().register(DockerScanner())
44
+ get_registry().register(NodeScanner())
45
+ get_registry().register(BrewScanner())
46
+ get_registry().register(XcodeScanner())
47
+ get_registry().register(StartupScanner())
48
+ get_registry().register(MemoryScanner())
49
+ get_registry().register(GraphScanner())
50
+ get_registry().register(ProjectPurgeScanner())
51
+ # Start on splash
52
+ self.push_screen("splash")
53
+
54
+ def on_exit(self) -> None:
55
+ """Cancel any still-running background tasks on quit so subprocesses
56
+ are not orphaned."""
57
+ for task in asyncio.all_tasks():
58
+ if task is not asyncio.current_task():
59
+ task.cancel()
@@ -0,0 +1 @@
1
+ """Slash command providers for Textual command palette."""