Skip to content

Commit 899f40e

Browse files
✨ Add Windows and --details
2 parents e0a08af + 6a13b5d commit 899f40e

File tree

9 files changed

+276
-84
lines changed

9 files changed

+276
-84
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Set up Python
2525
uses: actions/setup-python@v6
2626
with:
27-
python-version: "3.12"
27+
python-version: "3.13"
2828

2929
- name: Build package
3030
run: uv build

.github/workflows/lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ jobs:
1919
- uses: actions/checkout@v4
2020

2121
- name: Install uv
22-
uses: astral-sh/setup-uv@v4
22+
uses: astral-sh/setup-uv@v7
2323
with:
2424
enable-cache: true
2525

2626
- name: Set up Python
2727
uses: actions/setup-python@v5
2828
with:
29-
python-version: "3.12"
29+
python-version: "3.13"
3030

3131
- name: Install dependencies
3232
run: uv sync

.github/workflows/test.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ on:
1414

1515
jobs:
1616
test:
17-
runs-on: ubuntu-latest
17+
runs-on: ${{ matrix.os }}
18+
strategy:
19+
matrix:
20+
os: [ubuntu-latest, windows-latest, macos-latest]
1821
steps:
1922
- uses: actions/checkout@v5
2023

@@ -23,11 +26,12 @@ jobs:
2326
with:
2427
python-version: "3.13"
2528

26-
- name: Install system dependencies
29+
- name: Install system dependencies (Linux only)
30+
if: runner.os == 'Linux'
2731
run: sudo apt-get update && sudo apt-get install -y lsof
2832

2933
- name: Install uv
30-
uses: astral-sh/setup-uv@v5
34+
uses: astral-sh/setup-uv@v7
3135
with:
3236
enable-cache: true
3337
- name: Install dependencies

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.14
1+
3.13

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A tiny, beautiful TUI program for viewing and killing processes listening on ports.
44

5-
![Gruyère Screenshot](gruyere.gif)
5+
![Gruyère Screenshot](https://raw.githubusercontent.com/savannahostrowski/gruyere/main/gruyere.gif)
66

77
**Install:**
88
```bash
@@ -15,7 +15,10 @@ pip install gruyere
1515

1616
**Usage:**
1717
```bash
18-
gruyere
18+
gruyere # Show processes with clean app names
19+
gruyere --details # Show full command details
20+
gruyere --port 8000 # Filter by specific port
21+
gruyere --user username # Filter by specific user
1922
```
2023

2124
### Controls
@@ -29,14 +32,18 @@ gruyere
2932
## Features
3033

3134
- 🎨 Beautiful gradient UI with rich colors
32-
- 🔍 Filter processes by command name
35+
- 🔍 Filter processes by command name, port, or user
36+
- 📱 Clean app names by default, with optional `--details` flag to show full command strings
3337
- ⌨️ Vim-style navigation (j/k) or arrow keys
3438
- 💀 Kill processes with confirmation dialog
3539
- 📄 Pagination for many processes
3640

3741
## Requirements
3842

39-
- macOS or Linux (uses `lsof`)
43+
- macOS, Linux, or Windows
44+
- Python 3.13+
45+
46+
**Note:** On macOS, the program will run without elevated privileges but will only show processes owned by the current user. For system-wide process information, run with `sudo`. On Windows, you may need to run as Administrator to see all processes.
4047

4148
## License
4249

gruyere/main.py

Lines changed: 136 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
from dataclasses import dataclass
1+
import os
22
import signal
3-
import subprocess
43
import sys
4+
from dataclasses import dataclass
55
from typing import List, Optional
66

7+
import psutil
78
import typer
8-
from readchar import readkey, key
9+
from readchar import key, readkey
10+
from rich import box
911
from rich.color import Color
1012
from rich.console import Console, Group
1113
from rich.live import Live
12-
from rich.style import Style
1314
from rich.panel import Panel
15+
from rich.style import Style
1416
from rich.text import Text
15-
from rich import box
1617

1718

1819
@dataclass
@@ -21,6 +22,7 @@ class Process:
2122
port: int | str
2223
user: str
2324
command: str
25+
name: str
2426

2527

2628
SELECTED_COLOR = Style(color="#EE6FF8", bold=True)
@@ -44,27 +46,118 @@ def parse_port(port_str: str) -> int | str:
4446
return port_str
4547

4648

47-
def get_processes() -> list[Process]:
48-
raw_processes = subprocess.run(
49-
["lsof", "-i", "-P", "-n", "-sTCP:LISTEN"], capture_output=True, text=True
49+
def extract_app_name(command: str) -> str:
50+
if not command or command == "N/A":
51+
return command
52+
53+
# Handle macOS .app bundles - extract the main app name from the full command string
54+
if ".app/" in command:
55+
first_app_end = command.find(".app/") + 4
56+
path_before_app = command[:first_app_end]
57+
app_start = path_before_app.rfind("/")
58+
if app_start != -1:
59+
app_name = path_before_app[app_start + 1 :]
60+
return app_name.replace(".app", "")
61+
62+
# Handle Windows .exe paths - look for .exe to find the executable
63+
if ".exe" in command:
64+
exe_end = command.find(".exe") + 4
65+
path_before_exe = command[:exe_end]
66+
# Find the last backslash or forward slash
67+
exe_start = max(path_before_exe.rfind("\\"), path_before_exe.rfind("/"))
68+
if exe_start != -1:
69+
exe_name = path_before_exe[exe_start + 1 :]
70+
return exe_name.replace(".exe", "")
71+
72+
# For other executables, get the first word/path component
73+
parts = command.split()
74+
if not parts:
75+
return command
76+
77+
executable = parts[0]
78+
basename = os.path.basename(executable)
79+
80+
# Remove common helper suffixes for cleaner names
81+
basename = (
82+
basename.replace(" (Plugin)", "")
83+
.replace(" (Renderer)", "")
84+
.replace(" (GPU)", "")
5085
)
51-
processes: list[Process] = []
5286

53-
for line in raw_processes.stdout.splitlines()[1:]:
54-
parts = line.split()
55-
if len(parts) >= 9:
56-
pid = int(parts[1])
57-
user = parts[2]
58-
command = parts[0]
59-
port = parse_port(parts[8].split(":")[-1])
60-
process = Process(pid=pid, port=port, user=user, command=command)
61-
processes.append(process)
87+
return basename
88+
6289

90+
def get_processes() -> list[Process]:
91+
"""Get a list of processes with their associated ports."""
92+
processes: list[Process] = []
93+
try:
94+
connections = psutil.net_connections(kind="inet")
95+
for conn in connections:
96+
if (
97+
conn.laddr
98+
and conn.status == psutil.CONN_LISTEN
99+
and conn.pid is not None
100+
):
101+
pid = conn.pid
102+
port = parse_port(str(conn.laddr.port))
103+
try:
104+
proc = psutil.Process(pid)
105+
user = proc.username()
106+
command = (
107+
" ".join(proc.cmdline()) if proc.cmdline() else proc.name()
108+
)
109+
except (psutil.NoSuchProcess, psutil.AccessDenied):
110+
user = "N/A"
111+
command = "N/A"
112+
113+
name = extract_app_name(command)
114+
processes.append(
115+
Process(pid=pid, port=port, user=user, command=command, name=name)
116+
)
117+
except psutil.AccessDenied:
118+
# On macOS, net_connections() requires elevated privileges
119+
# Fall back to checking each process individually
120+
for proc in psutil.process_iter(["pid"]):
121+
try:
122+
pid = proc.info["pid"]
123+
proc_connections = proc.net_connections(kind="inet")
124+
for conn in proc_connections:
125+
if conn.laddr and conn.status == psutil.CONN_LISTEN:
126+
port = parse_port(str(conn.laddr.port))
127+
try:
128+
user = proc.username()
129+
# get last part of command line or name if empty
130+
command = (
131+
" ".join(proc.cmdline())
132+
if proc.cmdline()
133+
else proc.name()
134+
)
135+
except (psutil.NoSuchProcess, psutil.AccessDenied):
136+
user = "N/A"
137+
command = "N/A"
138+
139+
name = extract_app_name(command)
140+
processes.append(
141+
Process(
142+
pid=pid,
143+
port=port,
144+
user=user,
145+
command=command,
146+
name=name,
147+
)
148+
)
149+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
150+
# Skip processes we can't access
151+
continue
152+
153+
# Sort processes by port number (numeric ports first, then strings)
154+
processes.sort(key=lambda p: (isinstance(p.port, str), p.port))
63155
return processes
64156

65157

66158
def kill_process(pid: int):
67-
subprocess.run(["kill", "-9", str(pid)])
159+
proc = psutil.Process(pid)
160+
proc.kill()
68161

69162

70163
def _show_pagination_indicator(total: int, selected: int, panels: list[Panel | str]):
@@ -83,7 +176,9 @@ def _show_pagination_indicator(total: int, selected: int, panels: list[Panel | s
83176
panels.append(f" [dim]{indicator}[/dim]")
84177

85178

86-
def _render_processes_table(processes: List[Process], selected: int):
179+
def _render_processes_table(
180+
processes: List[Process], selected: int, show_details: bool = False
181+
):
87182
max_display = 4
88183

89184
if len(processes) <= max_display:
@@ -107,10 +202,15 @@ def _render_processes_table(processes: List[Process], selected: int):
107202
indicator = "▐ " if i == display_selected else " "
108203

109204
port_line = f"{indicator}[bold]Port: {process.port} (PID: {process.pid})[/bold]"
110-
user_line = (
111-
f"{indicator}[dim]User: {process.user}, Command: {process.command}[/dim]"
112-
)
113-
content = f"{port_line}\n{user_line}"
205+
app_line = f"{indicator}[dim]App: {process.name}, User: {process.user}[/dim]"
206+
207+
if show_details:
208+
# Show clean name AND full command details
209+
details_line = f"{indicator}[dim]Details: {process.command}[/dim]"
210+
content = f"{port_line}\n{app_line}\n{details_line}"
211+
else:
212+
# Show just clean app name and user
213+
content = f"{port_line}\n{app_line}"
114214

115215
# All items get a panel with no border
116216
panel = Panel(
@@ -247,17 +347,14 @@ def main(
247347
refresh_rate: int = typer.Option(
248348
10, "--refresh-rate", "-r", help="Display refresh rate per second"
249349
),
350+
details: bool = typer.Option(
351+
False, "--details", "-d", help="Show full command details instead of app name"
352+
),
250353
):
251354
console = Console()
252355
text = _render_title()
253356
processes: list[Process] = get_processes()
254357

255-
if sys.platform.startswith("win"):
256-
console.print(
257-
"[red]Error:[/red] This program is only supported on Unix-like systems."
258-
)
259-
sys.exit(1)
260-
261358
if port is not None:
262359
processes = [p for p in processes if p.port == port]
263360

@@ -288,7 +385,7 @@ def main(
288385
)
289386

290387
with Live(
291-
_render_processes_table(processes, selected),
388+
_render_processes_table(processes, selected, details),
292389
console=console,
293390
refresh_per_second=refresh_rate,
294391
) as live:
@@ -298,7 +395,9 @@ def main(
298395
is_filtering = False
299396
filter_text = ""
300397
processes = get_processes()
301-
live.update(_render_processes_table(processes, selected))
398+
live.update(
399+
_render_processes_table(processes, selected, details)
400+
)
302401
continue
303402
elif ch == key.UP or ch == "k":
304403
selected = max(0, selected - 1)
@@ -331,7 +430,8 @@ def main(
331430
border_style="magenta",
332431
)
333432
display = Group(
334-
filter_panel, _render_processes_table(processes, selected)
433+
filter_panel,
434+
_render_processes_table(processes, selected, details),
335435
)
336436
live.update(display)
337437
else:
@@ -348,7 +448,8 @@ def main(
348448
border_style="magenta",
349449
)
350450
display = Group(
351-
filter_panel, _render_processes_table(processes, selected)
451+
filter_panel,
452+
_render_processes_table(processes, selected, details),
352453
)
353454
live.update(display)
354455
continue # Skip the update at the end of the loop
@@ -359,7 +460,7 @@ def main(
359460
# Exit live context to show confirmation view
360461
process_to_kill = processes[selected]
361462
break
362-
live.update(_render_processes_table(processes, selected))
463+
live.update(_render_processes_table(processes, selected, details))
363464

364465
if process_to_kill is not None:
365466
_clear_screen()

0 commit comments

Comments
 (0)