devmedic 0.2.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.
devmedic/junk.py ADDED
@@ -0,0 +1,185 @@
1
+ """Find regenerable build/dependency folders across your projects."""
2
+ import os
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ from rich.progress import Progress, SpinnerColumn, TextColumn
7
+ from rich.table import Table
8
+
9
+ from .util import (HOME, confirm, console, dir_size, display_path, human_age, human_size,
10
+ remove_tree, short_path, size_style)
11
+
12
+ # name -> (kind, check) ; check(parent_dir, junk_dir) says whether it's really junk
13
+ def _sibling(*names):
14
+ return lambda parent, _d: any(os.path.exists(os.path.join(parent, n)) for n in names)
15
+
16
+ def _always(_parent, _d):
17
+ return True
18
+
19
+ def _is_venv(_parent, d):
20
+ return os.path.exists(os.path.join(d, "pyvenv.cfg"))
21
+
22
+ PATTERNS = {
23
+ "node_modules": ("Node deps", _always),
24
+ ".venv": ("Python venv", _is_venv),
25
+ "venv": ("Python venv", _is_venv),
26
+ "env": ("Python venv", _is_venv),
27
+ "target": ("Rust/Maven", _sibling("Cargo.toml", "pom.xml")),
28
+ ".next": ("Next.js build", _always),
29
+ ".nuxt": ("Nuxt build", _always),
30
+ ".svelte-kit": ("SvelteKit", _always),
31
+ ".parcel-cache": ("Parcel cache", _always),
32
+ ".turbo": ("Turbo cache", _always),
33
+ ".angular": ("Angular cache", _always),
34
+ ".gradle": ("Gradle cache", _sibling("build.gradle", "build.gradle.kts",
35
+ "settings.gradle", "settings.gradle.kts")),
36
+ ".dart_tool": ("Dart/Flutter", _sibling("pubspec.yaml")),
37
+ ".tox": ("tox envs", _always),
38
+ ".pytest_cache": ("pytest cache", _always),
39
+ ".mypy_cache": ("mypy cache", _always),
40
+ ".ruff_cache": ("ruff cache", _always),
41
+ }
42
+
43
+ # Never descend into these (huge, or not your projects).
44
+ SKIP_DIRS = {".git", ".cache", ".local", ".npm", ".cargo", ".rustup", "snap",
45
+ ".mozilla", ".config", ".vscode", ".vscode-server", ".var",
46
+ ".m2", ".gradle", ".nvm", ".pyenv", ".docker", ".Trash"}
47
+ # Skipped only directly under $HOME / filesystem root.
48
+ SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found"}
49
+
50
+
51
+ def find(root, max_depth=8):
52
+ """Yield (junk_path, kind) — does not descend into junk it found."""
53
+ root = os.path.abspath(root)
54
+ stack = [(root, 0)]
55
+ while stack:
56
+ current, depth = stack.pop()
57
+ try:
58
+ entries = list(os.scandir(current))
59
+ except OSError:
60
+ continue
61
+ for e in entries:
62
+ if not e.is_dir(follow_symlinks=False):
63
+ continue
64
+ pattern = PATTERNS.get(e.name)
65
+ if pattern and pattern[1](current, e.path):
66
+ yield e.path, pattern[0]
67
+ continue
68
+ at_top = current in (HOME, "/")
69
+ if depth < max_depth and e.name not in SKIP_DIRS and not (
70
+ at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP)):
71
+ stack.append((e.path, depth + 1))
72
+
73
+
74
+ def project_last_touched(junk_path):
75
+ """Newest mtime among the project's own top-level files (not the junk)."""
76
+ parent = os.path.dirname(junk_path)
77
+ newest = 0
78
+ try:
79
+ for e in os.scandir(parent):
80
+ if e.path == junk_path or e.name in PATTERNS:
81
+ continue
82
+ try:
83
+ newest = max(newest, e.stat(follow_symlinks=False).st_mtime)
84
+ except OSError:
85
+ pass
86
+ except OSError:
87
+ pass
88
+ return newest or os.stat(junk_path).st_mtime
89
+
90
+
91
+ def scan(root, older_than_days=0):
92
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
93
+ console=console, transient=True) as prog:
94
+ task = prog.add_task(f"Scanning {short_path(os.path.abspath(root))} …")
95
+ found = []
96
+ for path, kind in find(root):
97
+ found.append((path, kind))
98
+ prog.update(task, description=f"Found {len(found)} — {short_path(path)}")
99
+ prog.update(task, description=f"Measuring {len(found)} folders …")
100
+ with ThreadPoolExecutor(max_workers=8) as pool:
101
+ sizes = list(pool.map(dir_size, [p for p, _ in found]))
102
+ cutoff = time.time() - older_than_days * 86400
103
+ items = []
104
+ for (path, kind), size in zip(found, sizes):
105
+ touched = project_last_touched(path)
106
+ if older_than_days and touched > cutoff:
107
+ continue
108
+ items.append({"path": path, "kind": kind, "size": size, "touched": touched})
109
+ items.sort(key=lambda i: i["size"], reverse=True)
110
+ return items
111
+
112
+
113
+ def show(items, limit=40, root=None):
114
+ if not items:
115
+ console.print("[green]No junk folders found. Clean machine! ✨[/]")
116
+ return
117
+ total = sum(i["size"] for i in items)
118
+ t = Table(title=f"Regenerable folders — {human_size(total)} total",
119
+ title_justify="left")
120
+ t.add_column("#", justify="right", style="dim")
121
+ t.add_column("Size", justify="right")
122
+ t.add_column("Type")
123
+ t.add_column("Project last touched", justify="right")
124
+ t.add_column("Path", overflow="fold")
125
+ for n, i in enumerate(items[:limit], 1):
126
+ t.add_row(str(n), f"[{size_style(i['size'])}]{human_size(i['size'])}[/]",
127
+ i["kind"], human_age(i["touched"]), display_path(i["path"], root))
128
+ console.print(t)
129
+ if len(items) > limit:
130
+ rest = items[limit:]
131
+ console.print(f"[dim]…and {len(rest)} more "
132
+ f"({human_size(sum(i['size'] for i in rest))}). Use --limit.[/]")
133
+
134
+
135
+ def parse_selection(text, count):
136
+ """'1,3,5-7' / 'all' -> sorted list of 0-based indices."""
137
+ text = text.strip().lower()
138
+ if text in ("a", "all"):
139
+ return list(range(count))
140
+ picked = set()
141
+ for part in text.replace(" ", ",").split(","):
142
+ if not part:
143
+ continue
144
+ if "-" in part:
145
+ lo, hi = part.split("-", 1)
146
+ picked.update(range(int(lo), int(hi) + 1))
147
+ else:
148
+ picked.add(int(part))
149
+ return sorted(i - 1 for i in picked if 1 <= i <= count)
150
+
151
+
152
+ def clean(items, assume_yes=False, limit=40):
153
+ if not items:
154
+ return
155
+ shown = items[:limit]
156
+ if assume_yes:
157
+ chosen = shown
158
+ else:
159
+ try:
160
+ answer = console.input("[bold]Delete which?[/] [dim](e.g. 1,3,5-8 / all / "
161
+ "Enter to cancel)[/] ")
162
+ chosen = [shown[i] for i in parse_selection(answer, len(shown))]
163
+ except ValueError:
164
+ console.print("[red]Couldn't parse that selection.[/]")
165
+ return
166
+ except (EOFError, KeyboardInterrupt):
167
+ console.print()
168
+ return
169
+ if not chosen:
170
+ console.print("Nothing deleted.")
171
+ return
172
+ total = sum(i["size"] for i in chosen)
173
+ console.print(f"About to delete [bold]{len(chosen)}[/] folders "
174
+ f"([bold]{human_size(total)}[/]). They can be regenerated "
175
+ f"(npm install, pip install, cargo build…).")
176
+ if not confirm("Proceed?", assume_yes):
177
+ return
178
+ freed = 0
179
+ for i in chosen:
180
+ if remove_tree(i["path"]):
181
+ freed += i["size"]
182
+ console.print(f"[green]✔[/] {short_path(i['path'])}")
183
+ else:
184
+ console.print(f"[red]✘ couldn't fully remove[/] {short_path(i['path'])}")
185
+ console.print(f"[bold green]Freed {human_size(freed)}[/]")
devmedic/menu.py ADDED
@@ -0,0 +1,295 @@
1
+ """Interactive section picker: `devmedic` with no arguments in a terminal."""
2
+ import os
3
+ import select
4
+ import sys
5
+
6
+ from rich.console import Group
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from . import caches, dashboard as db, ports, procs, repos
12
+ from .util import console, human_size, short_path
13
+
14
+ # ───────────────────────── section details ─────────────────────────
15
+
16
+
17
+ def detail_system(d):
18
+ console.print(db.system_panel(d))
19
+ t = Table(title="Per-core usage", title_justify="left", box=None, padding=(0, 1))
20
+ t.add_column("Core", style="bold grey70", justify="right")
21
+ t.add_column("Usage")
22
+ for i, pct in enumerate(d["cpu"]):
23
+ t.add_row(str(i), db.bar(pct, 30))
24
+ console.print(t)
25
+
26
+
27
+ def detail_memory(d):
28
+ console.print(db.memory_panel(d, limit=12))
29
+ console.print("[dim]Processes are grouped by app (e.g. all browser tabs together).[/]")
30
+
31
+
32
+ def detail_storage(d):
33
+ console.print(db.storage_panel(d))
34
+ console.print("[dim]Free space fast: devmedic junk --clean · devmedic caches[/]")
35
+
36
+
37
+ def detail_network(d):
38
+ console.print(db.network_panel(d))
39
+ ports.show()
40
+
41
+
42
+ def detail_tools(d):
43
+ console.print(db.tools_panel(d))
44
+ dk = d["docker"]
45
+ if dk["state"] == "ok" and dk["containers"]:
46
+ t = Table(title="Running containers", title_justify="left")
47
+ for col in ("Name", "Image", "Status"):
48
+ t.add_column(col)
49
+ for row in dk["containers"]:
50
+ t.add_row(*row[:3])
51
+ console.print(t)
52
+ elif dk["state"] == "denied":
53
+ console.print("Fix Docker permissions: [bold]sudo usermod -aG docker $USER[/] "
54
+ "then log out and back in.")
55
+
56
+
57
+ def detail_projects(d):
58
+ procs.show(d["procs"])
59
+ console.print()
60
+ repos.show(d["git"]["infos"], show_all=True)
61
+
62
+
63
+ def detail_cleanup(d):
64
+ user_c, sys_c = d["caches"]
65
+ caches.show(user_c, sys_c)
66
+ j = d["junk"]
67
+ console.print()
68
+ if j["count"]:
69
+ console.print(f"[bold]{j['count']} junk folders[/] ({human_size(j['total'])}), biggest:")
70
+ for size, path in j["top"]:
71
+ console.print(f" {human_size(size):>9} {short_path(path)}")
72
+ console.print("[dim]Review & delete: devmedic junk --clean[/]")
73
+ else:
74
+ console.print("[green]No node_modules / .venv / target junk found in ~ ✨[/]")
75
+
76
+
77
+ def detail_notes(d):
78
+ _, tips = db.health(d)
79
+ console.print(db.tips_panel(tips))
80
+
81
+
82
+ # ───────────────────────── menu summaries ─────────────────────────
83
+
84
+
85
+ def _c(text, color):
86
+ return f"[{color}]{text}[/]"
87
+
88
+
89
+ def sum_system(d):
90
+ s, cores = d["sys"], d["cpu"]
91
+ total = sum(cores) / len(cores) if cores else 0
92
+ parts = [_c(f"CPU {total:.0f}%", db.level_color(total))]
93
+ if s["temp"]:
94
+ parts.append(_c(f"{s['temp']:.0f}°C", db.level_color(s["temp"], 70, 85)))
95
+ if s["battery"]:
96
+ b = s["battery"]
97
+ parts.append(_c(f"battery {b.percent:.0f}%{' ⚡' if b.power_plugged else ''}",
98
+ db.level_color(100 - b.percent, 70, 85)))
99
+ return " · ".join(parts)
100
+
101
+
102
+ def sum_memory(d):
103
+ m = d["mem"]
104
+ top = d["hogs"][0] if d["hogs"] else None
105
+ return (_c(f"RAM {m.percent:.0f}%", db.level_color(m.percent, 75, 90))
106
+ + (f" · {top[0]} {human_size(top[1])}" if top else ""))
107
+
108
+
109
+ def sum_storage(d):
110
+ return " · ".join(_c(f"{x['mount']} {x['pct']:.0f}%", db.level_color(x["pct"]))
111
+ for x in d["disks"][:3])
112
+
113
+
114
+ def sum_network(d):
115
+ n = d["net"]
116
+ where = n["wifi"] or (n["ips"][0][0] if n["ips"] else None)
117
+ ip = n["ips"][0][1] if n["ips"] else None
118
+ head = f"{where} {ip}" if ip else _c("offline", "yellow")
119
+ return f"{head} · {db.plural(len(d['ports']), 'port')} listening"
120
+
121
+
122
+ def sum_tools(d):
123
+ installed = sum(1 for _, v in d["tools"] if v)
124
+ dk = {"ok": _c("docker ✔", "green"), "denied": _c("docker needs permission", "yellow"),
125
+ "down": _c("docker stopped", "grey62"), "missing": ""}[d["docker"]["state"]]
126
+ return " · ".join(x for x in (f"{installed} tools installed", dk) if x)
127
+
128
+
129
+ def sum_projects(d):
130
+ g = d["git"]
131
+ risky = len(g["noremote"]) + len(g["unpushed"])
132
+ parts = [f"{db.plural(g['total'], 'repo')}"]
133
+ parts.append(_c(f"{risky} not backed up", "red") if risky else _c("all backed up", "green"))
134
+ parts.append(f"{db.plural(len(d['procs']), 'dev process', 'dev processes')}")
135
+ return " · ".join(parts)
136
+
137
+
138
+ def sum_cleanup(d):
139
+ user_c, sys_c = d["caches"]
140
+ total = (sum(c["size"] for c in user_c) + sum(s["size"] or 0 for s in sys_c)
141
+ + d["junk"]["total"])
142
+ color = "red" if total > 5 * 1024**3 else "yellow" if total > 300 * 1024**2 else "green"
143
+ return _c(f"{human_size(total)} reclaimable", color)
144
+
145
+
146
+ def sum_notes(d):
147
+ _, tips = db.health(d)
148
+ return _c(f"{db.plural(len(tips), 'suggestion')}", "yellow") if tips else _c("all good ✨", "green")
149
+
150
+
151
+ # key, icon, title, summary(d), detail(d)
152
+ SECTIONS = [
153
+ ("system", "⚡", "System", sum_system, detail_system),
154
+ ("memory", "🧠", "Memory", sum_memory, detail_memory),
155
+ ("storage", "💾", "Storage", sum_storage, detail_storage),
156
+ ("network", "🌐", "Network", sum_network, detail_network),
157
+ ("tools", "🛠 ", "Dev tools", sum_tools, detail_tools),
158
+ ("projects", "📂", "Projects", sum_projects, detail_projects),
159
+ ("cleanup", "🧹", "Cleanup", sum_cleanup, detail_cleanup),
160
+ ("notes", "💡", "Doctor's notes", sum_notes, detail_notes),
161
+ ]
162
+ EXTRAS = [ # key, icon, title, hint
163
+ ("all", "📊", "Everything", "full dashboard on one screen"),
164
+ ("watch", "📡", "Live monitor", "real-time CPU / RAM / network"),
165
+ ("refresh", "🔄", "Refresh", "re-scan the machine"),
166
+ ("quit", "🚪", "Quit", ""),
167
+ ]
168
+ SECTION_KEYS = [s[0] for s in SECTIONS]
169
+
170
+
171
+ def show_section(key, d=None):
172
+ """Print one section (used by `devmedic memory` etc.)."""
173
+ d = d or db.gather()
174
+ for k, icon, title, _, detail in SECTIONS:
175
+ if k == key:
176
+ if key in ("projects", "cleanup"): # the others open with their own panel
177
+ console.rule(f"[bold cyan]{icon} {title}[/]", align="left", style="cyan")
178
+ detail(d)
179
+ return
180
+
181
+
182
+ # ───────────────────────── keyboard ─────────────────────────
183
+
184
+ KEYS = {b"\x1b[A": "up", b"\x1bOA": "up", b"k": "up",
185
+ b"\x1b[B": "down", b"\x1bOB": "down", b"j": "down",
186
+ b"\r": "enter", b"\n": "enter", b" ": "enter", b"\x1b[C": "enter", b"l": "enter",
187
+ b"q": "quit", b"Q": "quit", b"\x1b": "back", b"\x1b[D": "back", b"h": "back",
188
+ b"r": "refresh", b"a": "all", b"w": "watch", b"\x1b[H": "home", b"\x1b[F": "end"}
189
+
190
+
191
+ def read_key():
192
+ import termios # POSIX only — imported here so the package loads everywhere
193
+ import tty
194
+
195
+ fd = sys.stdin.fileno()
196
+ old = termios.tcgetattr(fd)
197
+ try:
198
+ tty.setcbreak(fd)
199
+ data = os.read(fd, 1)
200
+ # Escape sequences arrive as a burst; grab the rest if it's there.
201
+ while data.startswith(b"\x1b") and select.select([fd], [], [], 0.03)[0]:
202
+ data += os.read(fd, 8)
203
+ finally:
204
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
205
+ if data.isdigit():
206
+ return data.decode()
207
+ return KEYS.get(data, "other")
208
+
209
+
210
+ def wait_key(msg="Press any key to go back · q to quit"):
211
+ console.print()
212
+ console.print(Text(f" {msg}", style="grey50"))
213
+ return read_key()
214
+
215
+
216
+ # ───────────────────────── menu screen ─────────────────────────
217
+
218
+
219
+ def menu_body(d, cursor):
220
+ items = [(k, icon, title, Text.from_markup(summ(d))) for k, icon, title, summ, _ in SECTIONS]
221
+ items += [(k, icon, title, Text(hint, style="grey50")) for k, icon, title, hint in EXTRAS]
222
+ t = Table.grid(padding=(0, 1))
223
+ t.add_column(width=2) # pointer
224
+ t.add_column(width=3, justify="right", style="grey50") # shortcut
225
+ t.add_column(no_wrap=True) # icon + title
226
+ t.add_column() # summary
227
+ for i, (key, icon, title, summary) in enumerate(items):
228
+ if i == len(SECTIONS):
229
+ t.add_row("", "", Text("─" * 18, style="grey35"), "")
230
+ selected = i == cursor
231
+ shortcut = str(i + 1) if i < len(SECTIONS) else {"all": "a", "watch": "w",
232
+ "refresh": "r", "quit": "q"}[key]
233
+ label = Text(f"{icon} {title}", style="bold reverse cyan" if selected else "bold")
234
+ t.add_row(Text("❯", style="bold magenta") if selected else "", shortcut, label, summary)
235
+ return t, [it[0] for it in items]
236
+
237
+
238
+ def render_menu(d, cursor):
239
+ score, _ = db.health(d)
240
+ body, keys = menu_body(d, cursor)
241
+ footer = Text("↑↓ / j k move · Enter open · 1-8 jump · a all · w live · r refresh · q quit",
242
+ style="grey50")
243
+ menu = Panel(Group(body, Text(""), footer), title="[bold cyan]Choose a section[/]",
244
+ title_align="left", border_style="cyan", padding=(1, 2))
245
+ return Group(db.header(d, score), Text(""), menu), keys
246
+
247
+
248
+ def run():
249
+ d = db.gather()
250
+ cursor = 0
251
+ while True:
252
+ console.clear()
253
+ screen, keys = render_menu(d, cursor)
254
+ console.print(screen)
255
+ key = read_key()
256
+ if key.isdigit():
257
+ n = int(key) - 1
258
+ if 0 <= n < len(keys):
259
+ cursor = n
260
+ key = "enter"
261
+ else:
262
+ continue
263
+ if key in ("all", "watch", "refresh"):
264
+ cursor = keys.index(key)
265
+ key = "enter"
266
+ if key == "up":
267
+ cursor = (cursor - 1) % len(keys)
268
+ elif key == "down":
269
+ cursor = (cursor + 1) % len(keys)
270
+ elif key == "home":
271
+ cursor = 0
272
+ elif key == "end":
273
+ cursor = len(keys) - 1
274
+ elif key in ("quit", "back"):
275
+ break
276
+ elif key == "enter":
277
+ choice = keys[cursor]
278
+ if choice == "quit":
279
+ break
280
+ if choice == "refresh":
281
+ console.clear()
282
+ d = db.gather()
283
+ continue
284
+ console.clear()
285
+ if choice == "watch":
286
+ db.watch()
287
+ continue
288
+ if choice == "all":
289
+ db.show(d)
290
+ else:
291
+ show_section(choice, d)
292
+ if wait_key() == "quit":
293
+ break
294
+ console.clear()
295
+ console.print("[bold cyan]🩺 devmedic[/] — stay healthy! 👋")
devmedic/ports.py ADDED
@@ -0,0 +1,126 @@
1
+ """Who is listening on which port, and killing them."""
2
+ import os
3
+ import signal
4
+ import time
5
+
6
+ import psutil
7
+ from rich.table import Table
8
+
9
+ from .util import confirm, console, short_path
10
+
11
+
12
+ # Friendly names for common root-owned services we can't inspect.
13
+ WELL_KNOWN = {22: "sshd", 25: "smtp", 53: "DNS (systemd-resolved)", 80: "http",
14
+ 443: "https", 631: "CUPS printing", 3306: "MySQL", 5432: "PostgreSQL",
15
+ 6379: "Redis", 27017: "MongoDB", 5353: "mDNS (avahi)", 2375: "Docker",
16
+ 11434: "Ollama"}
17
+
18
+
19
+ def listening(include_system=False):
20
+ """List of dicts describing TCP listeners (deduped across IPv4/IPv6)."""
21
+ rows = {}
22
+ try:
23
+ conns = psutil.net_connections(kind="inet")
24
+ except (psutil.AccessDenied, OSError):
25
+ conns = [] # locked-down containers / non-Linux without root
26
+ for c in conns:
27
+ if c.status != psutil.CONN_LISTEN or not c.laddr:
28
+ continue
29
+ port = c.laddr.port
30
+ key = (port, c.pid)
31
+ if key in rows:
32
+ rows[key]["addrs"].add(c.laddr.ip)
33
+ continue
34
+ info = {"port": port, "pid": c.pid, "addrs": {c.laddr.ip},
35
+ "name": WELL_KNOWN.get(port, "?"), "cmd": "", "cwd": "", "user": "", "mine": False}
36
+ if c.pid:
37
+ try:
38
+ p = psutil.Process(c.pid)
39
+ info["name"] = p.name()
40
+ info["cmd"] = " ".join(" ".join(p.cmdline()).split())
41
+ info["user"] = p.username()
42
+ info["mine"] = p.uids().real == os.getuid()
43
+ info["cwd"] = p.cwd()
44
+ except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
45
+ pass
46
+ rows[key] = info
47
+ result = sorted(rows.values(), key=lambda r: r["port"])
48
+ if not include_system:
49
+ result = [r for r in result if r["mine"]]
50
+ return result
51
+
52
+
53
+ def exposure(addrs):
54
+ if all(a.startswith("127.") or a == "::1" for a in addrs):
55
+ return "[green]local only[/]"
56
+ return "[yellow]network[/]"
57
+
58
+
59
+ def show(include_system=False):
60
+ rows = listening(include_system)
61
+ if not rows:
62
+ console.print("[green]No listening ports owned by you.[/] "
63
+ "[dim](use --all to include system services)[/]")
64
+ return
65
+ t = Table(title="Listening ports", title_justify="left")
66
+ t.add_column("Port", style="bold cyan", justify="right")
67
+ t.add_column("PID", justify="right")
68
+ t.add_column("Process")
69
+ t.add_column("Exposed")
70
+ t.add_column("Project dir", style="dim")
71
+ t.add_column("Command", overflow="ellipsis", max_width=60, no_wrap=True)
72
+ for r in rows:
73
+ t.add_row(str(r["port"]), str(r["pid"] or "?"), r["name"],
74
+ exposure(r["addrs"]), short_path(r["cwd"]) if r["cwd"] else "",
75
+ r["cmd"])
76
+ console.print(t)
77
+ if not include_system:
78
+ console.print("[dim]Showing your processes only. --all for system services.[/]")
79
+
80
+
81
+ def kill(port, force=False, assume_yes=False):
82
+ targets = [r for r in listening(include_system=True) if r["port"] == port]
83
+ if not targets:
84
+ console.print(f"[green]Nothing is listening on port {port}.[/]")
85
+ return 0
86
+ status = 0
87
+ for r in targets:
88
+ if not r["pid"]:
89
+ console.print(f"[red]Port {port} is held by a process you can't see "
90
+ f"(probably root).[/] Try: [bold]sudo ss -ltnp 'sport = :{port}'[/]")
91
+ status = 1
92
+ continue
93
+ console.print(f"Port [bold cyan]{port}[/] → PID {r['pid']} "
94
+ f"[bold]{r['name']}[/] [dim]{r['cmd'][:100]}[/]")
95
+ if not r["mine"]:
96
+ console.print(f"[yellow]Owned by {r['user'] or 'another user'}; "
97
+ f"you may need sudo.[/]")
98
+ if not confirm("Kill it?", assume_yes):
99
+ continue
100
+ status |= terminate(r["pid"], force)
101
+ return status
102
+
103
+
104
+ def terminate(pid, force=False):
105
+ try:
106
+ p = psutil.Process(pid)
107
+ if force:
108
+ p.kill()
109
+ else:
110
+ p.terminate()
111
+ try:
112
+ p.wait(timeout=3)
113
+ except psutil.TimeoutExpired:
114
+ console.print("[yellow]Didn't exit after SIGTERM, sending SIGKILL…[/]")
115
+ p.kill()
116
+ p.wait(timeout=3)
117
+ except psutil.NoSuchProcess:
118
+ pass
119
+ except psutil.AccessDenied:
120
+ console.print(f"[red]Permission denied.[/] Try: [bold]sudo kill {pid}[/]")
121
+ return 1
122
+ except psutil.TimeoutExpired:
123
+ console.print(f"[red]PID {pid} is still alive.[/]")
124
+ return 1
125
+ console.print(f"[green]✔ Killed PID {pid}[/]")
126
+ return 0