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/__init__.py +1 -0
- devmedic/__main__.py +5 -0
- devmedic/caches.py +173 -0
- devmedic/cli.py +152 -0
- devmedic/dashboard.py +645 -0
- devmedic/junk.py +185 -0
- devmedic/menu.py +295 -0
- devmedic/ports.py +126 -0
- devmedic/procs.py +113 -0
- devmedic/repos.py +129 -0
- devmedic/util.py +109 -0
- devmedic-0.2.0.dist-info/METADATA +133 -0
- devmedic-0.2.0.dist-info/RECORD +17 -0
- devmedic-0.2.0.dist-info/WHEEL +5 -0
- devmedic-0.2.0.dist-info/entry_points.txt +2 -0
- devmedic-0.2.0.dist-info/licenses/LICENSE +21 -0
- devmedic-0.2.0.dist-info/top_level.txt +1 -0
devmedic/procs.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Dev processes grouped by the project (cwd) they were started from."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
import psutil
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .util import HOME, console, human_age, human_size, short_path, size_style
|
|
10
|
+
|
|
11
|
+
DEV_NAMES = {"node", "python", "python3", "java", "ruby", "go", "cargo", "rustc",
|
|
12
|
+
"deno", "bun", "php", "dotnet", "gradle", "mvn", "npm", "pnpm", "yarn",
|
|
13
|
+
"vite", "esbuild", "webpack", "next-server", "uvicorn", "gunicorn",
|
|
14
|
+
"flask", "celery", "jupyter", "jupyter-lab", "postgres", "mysqld",
|
|
15
|
+
"mongod", "redis-server", "tsserver", "ng", "air", "nodemon",
|
|
16
|
+
"docker-proxy", "containerd-shim", "gopls", "rust-analyzer", "pyright",
|
|
17
|
+
"language_server", "kotlin", "emulator", "qemu-system-x86_64", "adb"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_dev(name, cmdline):
|
|
21
|
+
base = name.split(".")[0].lower()
|
|
22
|
+
if base in DEV_NAMES or name.lower() in DEV_NAMES:
|
|
23
|
+
return True
|
|
24
|
+
return bool(cmdline) and os.path.basename(cmdline[0]).lower() in DEV_NAMES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def collect():
|
|
28
|
+
ports = defaultdict(set)
|
|
29
|
+
try:
|
|
30
|
+
for c in psutil.net_connections(kind="inet"):
|
|
31
|
+
if c.status == psutil.CONN_LISTEN and c.pid:
|
|
32
|
+
ports[c.pid].add(c.laddr.port)
|
|
33
|
+
except (psutil.AccessDenied, OSError):
|
|
34
|
+
pass
|
|
35
|
+
me, self_pid = os.getuid(), os.getpid()
|
|
36
|
+
rows = []
|
|
37
|
+
for p in psutil.process_iter(["pid", "name", "cmdline", "memory_info", "create_time",
|
|
38
|
+
"uids", "cwd", "cpu_percent"]):
|
|
39
|
+
info = p.info
|
|
40
|
+
if not info["uids"] or info["uids"].real != me or info["pid"] == self_pid:
|
|
41
|
+
continue
|
|
42
|
+
if not is_dev(info["name"] or "", info["cmdline"] or []):
|
|
43
|
+
continue
|
|
44
|
+
rows.append({"pid": info["pid"], "name": info["name"],
|
|
45
|
+
"cmd": " ".join(" ".join(info["cmdline"] or []).split()),
|
|
46
|
+
"rss": info["memory_info"].rss if info["memory_info"] else 0,
|
|
47
|
+
"started": info["create_time"] or time.time(),
|
|
48
|
+
"cwd": info["cwd"] or "?", "ports": sorted(ports.get(info["pid"], ())),
|
|
49
|
+
"proc": p})
|
|
50
|
+
# One CPU sample over a short window for everything at once.
|
|
51
|
+
for r in rows:
|
|
52
|
+
try:
|
|
53
|
+
r["proc"].cpu_percent(None)
|
|
54
|
+
except psutil.Error:
|
|
55
|
+
pass
|
|
56
|
+
time.sleep(0.5)
|
|
57
|
+
for r in rows:
|
|
58
|
+
try:
|
|
59
|
+
r["cpu"] = r["proc"].cpu_percent(None)
|
|
60
|
+
except psutil.Error:
|
|
61
|
+
r["cpu"] = 0.0
|
|
62
|
+
return rows
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def group(rows):
|
|
66
|
+
groups = defaultdict(list)
|
|
67
|
+
for r in rows:
|
|
68
|
+
groups[r["cwd"]].append(r)
|
|
69
|
+
out = [{"cwd": cwd, "procs": ps, "rss": sum(p["rss"] for p in ps),
|
|
70
|
+
"cpu": sum(p["cpu"] for p in ps),
|
|
71
|
+
"ports": sorted({x for p in ps for x in p["ports"]}),
|
|
72
|
+
"oldest": min(p["started"] for p in ps)}
|
|
73
|
+
for cwd, ps in groups.items()]
|
|
74
|
+
return sorted(out, key=lambda g: g["rss"], reverse=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def show(rows, detail=False):
|
|
78
|
+
if not rows:
|
|
79
|
+
console.print("[green]No dev processes running.[/]")
|
|
80
|
+
return
|
|
81
|
+
vm = psutil.virtual_memory()
|
|
82
|
+
groups = group(rows)
|
|
83
|
+
total = sum(g["rss"] for g in groups)
|
|
84
|
+
t = Table(title=f"Dev processes — {human_size(total)} RAM "
|
|
85
|
+
f"({total / vm.total * 100:.0f}% of {human_size(vm.total)})",
|
|
86
|
+
title_justify="left")
|
|
87
|
+
t.add_column("Started in", overflow="fold")
|
|
88
|
+
t.add_column("RAM", justify="right")
|
|
89
|
+
t.add_column("CPU", justify="right")
|
|
90
|
+
t.add_column("Procs", justify="right")
|
|
91
|
+
t.add_column("Ports", style="cyan")
|
|
92
|
+
t.add_column("Running for", justify="right")
|
|
93
|
+
t.add_column("What", overflow="ellipsis", no_wrap=True, max_width=50)
|
|
94
|
+
for g in groups:
|
|
95
|
+
names = sorted({p["name"] for p in g["procs"]})
|
|
96
|
+
cwd = g["cwd"]
|
|
97
|
+
label = "~" if cwd == HOME else short_path(cwd)
|
|
98
|
+
t.add_row(label, f"[{size_style(g['rss'] * 4)}]{human_size(g['rss'])}[/]",
|
|
99
|
+
f"{g['cpu']:.0f}%", str(len(g["procs"])),
|
|
100
|
+
", ".join(map(str, g["ports"])),
|
|
101
|
+
human_age(g["oldest"]).replace(" ago", ""), ", ".join(names))
|
|
102
|
+
if detail:
|
|
103
|
+
for p in sorted(g["procs"], key=lambda p: p["rss"], reverse=True):
|
|
104
|
+
t.add_row(f" [dim]pid {p['pid']}[/]", f"[dim]{human_size(p['rss'])}[/]",
|
|
105
|
+
f"[dim]{p['cpu']:.0f}%[/]", "",
|
|
106
|
+
", ".join(map(str, p["ports"])), "", f"[dim]{p['cmd']}[/]")
|
|
107
|
+
console.print(t)
|
|
108
|
+
stale = [g for g in groups if time.time() - g["oldest"] > 86400 and g["ports"]]
|
|
109
|
+
if stale:
|
|
110
|
+
console.print(f"[yellow]💡 {len(stale)} server(s) running for over a day — "
|
|
111
|
+
f"forgotten dev servers? Kill with [bold]devmedic kill <port>[/].[/]")
|
|
112
|
+
if not detail:
|
|
113
|
+
console.print("[dim]Use -v to see individual processes.[/]")
|
devmedic/repos.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Find git repos with work that isn't safely pushed."""
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .junk import PATTERNS, SKIP_AT_TOP, SKIP_DIRS
|
|
10
|
+
from .util import HOME, console, display_path, human_age, short_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find(root, max_depth=6):
|
|
14
|
+
root = os.path.abspath(root)
|
|
15
|
+
stack = [(root, 0)]
|
|
16
|
+
while stack:
|
|
17
|
+
current, depth = stack.pop()
|
|
18
|
+
if os.path.exists(os.path.join(current, ".git")):
|
|
19
|
+
yield current
|
|
20
|
+
# keep going: nested repos / submodules-as-dirs are common
|
|
21
|
+
try:
|
|
22
|
+
entries = list(os.scandir(current))
|
|
23
|
+
except OSError:
|
|
24
|
+
continue
|
|
25
|
+
at_top = current in (HOME, "/")
|
|
26
|
+
for e in entries:
|
|
27
|
+
if (depth < max_depth and e.is_dir(follow_symlinks=False)
|
|
28
|
+
and e.name not in SKIP_DIRS and e.name not in PATTERNS
|
|
29
|
+
and not (at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP))):
|
|
30
|
+
stack.append((e.path, depth + 1))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def git(repo, *args):
|
|
34
|
+
try:
|
|
35
|
+
r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
|
|
36
|
+
timeout=15)
|
|
37
|
+
return r.stdout if r.returncode == 0 else ""
|
|
38
|
+
except (OSError, subprocess.SubprocessError):
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def inspect(repo):
|
|
43
|
+
info = {"path": repo, "branch": "?", "ahead": 0, "behind": 0, "upstream": False,
|
|
44
|
+
"changed": 0, "untracked": 0, "stashes": 0, "last": 0, "no_remote": False,
|
|
45
|
+
"unpushed_branches": 0}
|
|
46
|
+
for line in git(repo, "status", "--porcelain=v2", "--branch").splitlines():
|
|
47
|
+
if line.startswith("# branch.head "):
|
|
48
|
+
info["branch"] = line.split(" ", 2)[2]
|
|
49
|
+
elif line.startswith("# branch.upstream "):
|
|
50
|
+
info["upstream"] = True
|
|
51
|
+
elif line.startswith("# branch.ab "):
|
|
52
|
+
a, b = line.split()[2:4]
|
|
53
|
+
info["ahead"], info["behind"] = int(a), -int(b)
|
|
54
|
+
elif line.startswith("?"):
|
|
55
|
+
info["untracked"] += 1
|
|
56
|
+
elif line[:1] in ("1", "2", "u"):
|
|
57
|
+
info["changed"] += 1
|
|
58
|
+
info["stashes"] = len(git(repo, "stash", "list").splitlines())
|
|
59
|
+
last = git(repo, "log", "-1", "--format=%ct").strip()
|
|
60
|
+
info["last"] = int(last) if last.isdigit() else 0
|
|
61
|
+
info["no_remote"] = not git(repo, "remote").strip()
|
|
62
|
+
# Local branches with commits not on any remote.
|
|
63
|
+
if not info["no_remote"]:
|
|
64
|
+
out = git(repo, "for-each-ref", "--format=%(refname:short)|%(upstream:track)",
|
|
65
|
+
"refs/heads")
|
|
66
|
+
for line in out.splitlines():
|
|
67
|
+
name, _, track = line.partition("|")
|
|
68
|
+
if name == info["branch"]:
|
|
69
|
+
continue # already covered by ahead/upstream
|
|
70
|
+
if "ahead" in track or (not track and
|
|
71
|
+
git(repo, "log", "--oneline", "-1", name, "--not",
|
|
72
|
+
"--remotes").strip()):
|
|
73
|
+
info["unpushed_branches"] += 1
|
|
74
|
+
return info
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def needs_attention(i):
|
|
78
|
+
return (i["changed"] or i["untracked"] or i["ahead"] or i["stashes"]
|
|
79
|
+
or i["unpushed_branches"] or i["no_remote"]
|
|
80
|
+
or (i["upstream"] is False and i["branch"] != "(detached)" and i["last"]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scan(root):
|
|
84
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
85
|
+
console=console, transient=True) as prog:
|
|
86
|
+
task = prog.add_task(f"Looking for git repos in {short_path(os.path.abspath(root))} …")
|
|
87
|
+
repos = list(find(root))
|
|
88
|
+
prog.update(task, description=f"Inspecting {len(repos)} repos …")
|
|
89
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
90
|
+
infos = list(pool.map(inspect, repos))
|
|
91
|
+
return sorted(infos, key=lambda i: i["last"], reverse=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def show(infos, show_all=False, root=None):
|
|
95
|
+
if not infos:
|
|
96
|
+
console.print("[dim]No git repositories found.[/]")
|
|
97
|
+
return
|
|
98
|
+
flagged = [i for i in infos if needs_attention(i)]
|
|
99
|
+
rows = infos if show_all else flagged
|
|
100
|
+
if not rows:
|
|
101
|
+
console.print(f"[green]All {len(infos)} repos are clean and pushed. ✨[/]")
|
|
102
|
+
return
|
|
103
|
+
t = Table(title=f"Git repos — {len(flagged)} of {len(infos)} need attention",
|
|
104
|
+
title_justify="left")
|
|
105
|
+
t.add_column("Repo", overflow="fold")
|
|
106
|
+
t.add_column("Branch", style="cyan")
|
|
107
|
+
t.add_column("Problems")
|
|
108
|
+
t.add_column("Last commit", justify="right")
|
|
109
|
+
for i in rows:
|
|
110
|
+
p = []
|
|
111
|
+
if i["changed"]:
|
|
112
|
+
p.append(f"[yellow]{i['changed']} modified[/]")
|
|
113
|
+
if i["untracked"]:
|
|
114
|
+
p.append(f"[yellow]{i['untracked']} untracked[/]")
|
|
115
|
+
if i["ahead"]:
|
|
116
|
+
p.append(f"[red]{i['ahead']} unpushed[/]")
|
|
117
|
+
if i["behind"]:
|
|
118
|
+
p.append(f"[blue]{i['behind']} behind[/]")
|
|
119
|
+
if i["stashes"]:
|
|
120
|
+
p.append(f"[magenta]{i['stashes']} stash[/]")
|
|
121
|
+
if i["unpushed_branches"]:
|
|
122
|
+
p.append(f"[red]{i['unpushed_branches']} other branch(es) unpushed[/]")
|
|
123
|
+
if i["no_remote"]:
|
|
124
|
+
p.append("[red]no remote (not backed up!)[/]")
|
|
125
|
+
elif not i["upstream"] and i["last"]:
|
|
126
|
+
p.append("[yellow]branch has no upstream[/]")
|
|
127
|
+
t.add_row(display_path(i["path"], root), i["branch"], ", ".join(p) or "[green]clean[/]",
|
|
128
|
+
human_age(i["last"]) if i["last"] else "no commits")
|
|
129
|
+
console.print(t)
|
devmedic/util.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared helpers: sizes, ages, confirmation prompts."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
HOME = os.path.expanduser("~")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dir_size(path):
|
|
14
|
+
"""Actual disk usage of a directory tree in bytes (doesn't follow symlinks)."""
|
|
15
|
+
total = 0
|
|
16
|
+
seen = set()
|
|
17
|
+
stack = [path]
|
|
18
|
+
while stack:
|
|
19
|
+
current = stack.pop()
|
|
20
|
+
try:
|
|
21
|
+
with os.scandir(current) as it:
|
|
22
|
+
for entry in it:
|
|
23
|
+
try:
|
|
24
|
+
st = entry.stat(follow_symlinks=False)
|
|
25
|
+
except OSError:
|
|
26
|
+
continue
|
|
27
|
+
if entry.is_dir(follow_symlinks=False):
|
|
28
|
+
stack.append(entry.path)
|
|
29
|
+
continue
|
|
30
|
+
# Hardlinked files (pnpm store, etc.) count once.
|
|
31
|
+
if st.st_nlink > 1:
|
|
32
|
+
key = (st.st_dev, st.st_ino)
|
|
33
|
+
if key in seen:
|
|
34
|
+
continue
|
|
35
|
+
seen.add(key)
|
|
36
|
+
total += st.st_blocks * 512
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
return total
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def human_size(n):
|
|
43
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
44
|
+
if abs(n) < 1024 or unit == "TB":
|
|
45
|
+
return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}"
|
|
46
|
+
n /= 1024
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def size_style(n):
|
|
50
|
+
if n >= 1024**3:
|
|
51
|
+
return "bold red"
|
|
52
|
+
if n >= 200 * 1024**2:
|
|
53
|
+
return "yellow"
|
|
54
|
+
return "green"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def human_age(ts):
|
|
58
|
+
days = (time.time() - ts) / 86400
|
|
59
|
+
if days < 1:
|
|
60
|
+
hours = days * 24
|
|
61
|
+
return "just now" if hours < 1 else f"{hours:.0f}h ago"
|
|
62
|
+
if days < 60:
|
|
63
|
+
return f"{days:.0f}d ago"
|
|
64
|
+
if days < 730:
|
|
65
|
+
return f"{days / 30:.0f}mo ago"
|
|
66
|
+
return f"{days / 365:.1f}y ago"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def short_path(path):
|
|
70
|
+
return "~" + path[len(HOME):] if path.startswith(HOME) else path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def display_path(path, root=None):
|
|
74
|
+
"""Path relative to the scan root (when that's shorter), else ~-style."""
|
|
75
|
+
if root:
|
|
76
|
+
root = os.path.abspath(root)
|
|
77
|
+
if root != HOME and path.startswith(root + os.sep):
|
|
78
|
+
return os.path.relpath(path, root)
|
|
79
|
+
return short_path(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def confirm(question, assume_yes=False):
|
|
83
|
+
if assume_yes:
|
|
84
|
+
return True
|
|
85
|
+
try:
|
|
86
|
+
answer = console.input(f"[bold]{question}[/] [dim](y/N)[/] ")
|
|
87
|
+
except (EOFError, KeyboardInterrupt):
|
|
88
|
+
console.print()
|
|
89
|
+
return False
|
|
90
|
+
return answer.strip().lower() in ("y", "yes")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run(cmd, timeout=15):
|
|
94
|
+
"""Run a command, return stdout ('' on any failure)."""
|
|
95
|
+
try:
|
|
96
|
+
return subprocess.run(
|
|
97
|
+
cmd, capture_output=True, text=True, timeout=timeout
|
|
98
|
+
).stdout
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def have(binary):
|
|
104
|
+
return shutil.which(binary) is not None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def remove_tree(path):
|
|
108
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
109
|
+
return not os.path.exists(path)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devmedic
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A health check for your developer machine: ports, junk folders, caches, git repos, dev processes — offline.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/yaswanthme007/devmedic
|
|
7
|
+
Project-URL: Issues, https://github.com/yaswanthme007/devmedic/issues
|
|
8
|
+
Keywords: developer-tools,cli,linux,ubuntu,cleanup,ports,disk-usage
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: psutil>=5.8
|
|
20
|
+
Requires-Dist: rich>=12.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# 🩺 devmedic
|
|
24
|
+
|
|
25
|
+
**A health check for your developer machine.** Find what's hogging your ports, RAM and disk,
|
|
26
|
+
spot git work that isn't backed up, and clean up safely — all from one friendly terminal UI.
|
|
27
|
+
Works **100% offline**. No accounts, no telemetry, nothing leaves your machine.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
_ _ _
|
|
31
|
+
__| | ___ __ __ _ __ ___ ___ __| |(_) ___
|
|
32
|
+
/ _` | / _ \\ \ / /| '_ ` _ \ / _ \ / _` || | / __|
|
|
33
|
+
| (_| || __/ \ V / | | | | | || __/| (_| || || (__
|
|
34
|
+
\__,_| \___| \_/ |_| |_| |_| \___| \__,_||_| \___| HEALTH 92/100 A
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why?
|
|
38
|
+
|
|
39
|
+
Every developer hits these, over and over:
|
|
40
|
+
|
|
41
|
+
- 😤 `Error: listen EADDRINUSE: address already in use :::3000`
|
|
42
|
+
- 💾 Disk full — from 40 forgotten `node_modules`, `.venv`s and `target/` folders
|
|
43
|
+
- 🐢 Machine slow — three dev servers you started last week are still running
|
|
44
|
+
- 😱 "Did I ever push that project?"
|
|
45
|
+
|
|
46
|
+
devmedic answers all of them in seconds.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
Requires **Linux** (built and tested on Ubuntu) and **Python 3.9+**.
|
|
51
|
+
On **Windows**, use it inside [WSL](https://learn.microsoft.com/windows/wsl/install) for now; native support is planned.
|
|
52
|
+
|
|
53
|
+
**With pipx (recommended)**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
sudo apt install pipx && pipx ensurepath # once
|
|
57
|
+
pipx install devmedic
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**With pip**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install devmedic # inside a virtualenv, or: pip install --user devmedic
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**From a clone**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/yaswanthme007/devmedic.git
|
|
70
|
+
cd devmedic
|
|
71
|
+
./install.sh # uses pipx if present, otherwise a dev-mode link in ~/.local/bin
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Dev mode needs `sudo apt install python3-psutil python3-rich` (already present on most Ubuntu
|
|
75
|
+
desktops).
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
devmedic # interactive menu — pick a section with ↑↓ / 1-8 / Enter
|
|
81
|
+
devmedic all # everything on one screen (also used when output is piped)
|
|
82
|
+
devmedic watch # live full-screen monitor: CPU/RAM graphs, net speed, ports
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Sections** (open from the menu, or directly):
|
|
86
|
+
|
|
87
|
+
| Command | Shows |
|
|
88
|
+
|----------------------|---------------------------------------------------------------|
|
|
89
|
+
| `devmedic system` | CPU model, per-core usage, load, temperature, battery |
|
|
90
|
+
| `devmedic memory` | RAM/swap and the biggest apps (browser tabs grouped together) |
|
|
91
|
+
| `devmedic storage` | Usage of every real partition |
|
|
92
|
+
| `devmedic network` | IPs, Wi-Fi, traffic, listening ports |
|
|
93
|
+
| `devmedic tools` | Installed versions of git, python, node, go, rust, docker … |
|
|
94
|
+
| `devmedic projects` | Dev processes by project + every git repo's status |
|
|
95
|
+
| `devmedic cleanup` | How much space caches and junk folders are wasting |
|
|
96
|
+
| `devmedic notes` | The doctor's recommendations, with exact commands |
|
|
97
|
+
|
|
98
|
+
**Fix things:**
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
devmedic ports [-a] # what's listening, and from which project folder
|
|
102
|
+
devmedic kill 3000 # free port 3000 (SIGTERM, then SIGKILL if needed)
|
|
103
|
+
devmedic junk [PATH] # node_modules, .venv, target/, .next … with sizes
|
|
104
|
+
devmedic junk --older 30 --clean # pick stale ones to delete: 1,3,5-8 / all
|
|
105
|
+
devmedic caches # pip npm yarn pnpm cargo go gradle maven … + system
|
|
106
|
+
devmedic caches --clean pip npm # or --clean all (skips risky ones like Trash)
|
|
107
|
+
devmedic repos [PATH] # uncommitted, unpushed, stashed, or no remote at all
|
|
108
|
+
devmedic procs [-v] # dev processes grouped by project, RAM/CPU/ports
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Safety
|
|
112
|
+
|
|
113
|
+
devmedic is careful by design:
|
|
114
|
+
|
|
115
|
+
- **Nothing is deleted without confirmation** (unless you pass `-y`).
|
|
116
|
+
- **Conservative junk detection:** `target/` only next to `Cargo.toml`/`pom.xml`, venvs only if
|
|
117
|
+
they contain `pyvenv.cfg`. Generic `build/` and `dist/` folders are **never** touched.
|
|
118
|
+
- **Never runs `sudo`.** System fixes (journal, apt, snap, docker) are printed for you to copy.
|
|
119
|
+
- **Only kills what you confirm**, and can't touch other users' processes.
|
|
120
|
+
- **Offline and private:** no network requests, no data collection.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
./install.sh --dev # edits take effect immediately
|
|
126
|
+
python3 -m unittest discover -s tests -v # run the tests
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Ideas and pull requests are welcome! Please open an issue first for big changes.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
devmedic/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
2
|
+
devmedic/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
devmedic/caches.py,sha256=b3PGwVXITq1WJtlbvuLyhZhoLjm-jAZDCvKo-hN1b0A,7917
|
|
4
|
+
devmedic/cli.py,sha256=mHtE2vLVnvVsYN0pVNkAAtpeqyiM9MFX34QqieGEdOI,5098
|
|
5
|
+
devmedic/dashboard.py,sha256=MvLipL3QEHPyPTq1IBttcfR0LcvYoNnIH6HLGqIXtz0,25474
|
|
6
|
+
devmedic/junk.py,sha256=KWNVYPADHIzjuutz2ULeKFQAbDW1V4Bm1Tp3xktOjSY,7339
|
|
7
|
+
devmedic/menu.py,sha256=9Lu--X7SqzSjlSjxbaAlpYRq0Unt9Tk_i0B5lYTREjY,10906
|
|
8
|
+
devmedic/ports.py,sha256=rYxQjfACEG-YMCt0S5HQZ9KSTP4ia9FMr_ALnZyp8Bw,4637
|
|
9
|
+
devmedic/procs.py,sha256=PrSsIVW8j6UETvkc4FV1D_iHNKi50faF9Ip8CN61BCY,4860
|
|
10
|
+
devmedic/repos.py,sha256=8ekx-1ibscyyG0zjdquz3EyDiZxvL4P2UaM6HWt5J8k,5403
|
|
11
|
+
devmedic/util.py,sha256=NiEX1xxm3JJgF0NX43nygZEmdLD7MEaw3K45wQma8oA,2977
|
|
12
|
+
devmedic-0.2.0.dist-info/licenses/LICENSE,sha256=64qGbQIfOrXIorKNN0D5R4FacU3r3JcYMfp9R1mK83Q,1078
|
|
13
|
+
devmedic-0.2.0.dist-info/METADATA,sha256=34tipsbC8d0FRbBARU1BzNBgEuTRiKO2L22RYWsCYKY,5275
|
|
14
|
+
devmedic-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
devmedic-0.2.0.dist-info/entry_points.txt,sha256=e0NQcvPkfQo1HsNkpje7CpQiTqh0Z6RT-kw2AafF9hg,47
|
|
16
|
+
devmedic-0.2.0.dist-info/top_level.txt,sha256=ND2dM3AMx6h4OsMSqXTvHWfrlJp5hX5kOW81K0AqTZI,9
|
|
17
|
+
devmedic-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 devmedic contributors
|
|
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 @@
|
|
|
1
|
+
devmedic
|