Skip to content

Commit 2c877eb

Browse files
✨ Add Windows and --details
1 parent e0a08af commit 2c877eb

File tree

9 files changed

+249
-83
lines changed

9 files changed

+249
-83
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: 126 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,108 @@ 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+
# For other executables, get the first word/path component
63+
parts = command.split()
64+
if not parts:
65+
return command
66+
67+
executable = parts[0]
68+
basename = os.path.basename(executable)
69+
70+
# Remove common helper suffixes for cleaner names
71+
basename = (
72+
basename.replace(" (Plugin)", "")
73+
.replace(" (Renderer)", "")
74+
.replace(" (GPU)", "")
5075
)
51-
processes: list[Process] = []
5276

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)
77+
return basename
78+
6279

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

65147

66148
def kill_process(pid: int):
67-
subprocess.run(["kill", "-9", str(pid)])
149+
proc = psutil.Process(pid)
150+
proc.kill()
68151

69152

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

85168

86-
def _render_processes_table(processes: List[Process], selected: int):
169+
def _render_processes_table(
170+
processes: List[Process], selected: int, show_details: bool = False
171+
):
87172
max_display = 4
88173

89174
if len(processes) <= max_display:
@@ -107,10 +192,15 @@ def _render_processes_table(processes: List[Process], selected: int):
107192
indicator = "▐ " if i == display_selected else " "
108193

109194
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}"
195+
app_line = f"{indicator}[dim]App: {process.name}, User: {process.user}[/dim]"
196+
197+
if show_details:
198+
# Show clean name AND full command details
199+
details_line = f"{indicator}[dim]Details: {process.command}[/dim]"
200+
content = f"{port_line}\n{app_line}\n{details_line}"
201+
else:
202+
# Show just clean app name and user
203+
content = f"{port_line}\n{app_line}"
114204

115205
# All items get a panel with no border
116206
panel = Panel(
@@ -247,17 +337,14 @@ def main(
247337
refresh_rate: int = typer.Option(
248338
10, "--refresh-rate", "-r", help="Display refresh rate per second"
249339
),
340+
details: bool = typer.Option(
341+
False, "--details", "-d", help="Show full command details instead of app name"
342+
),
250343
):
251344
console = Console()
252345
text = _render_title()
253346
processes: list[Process] = get_processes()
254347

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-
261348
if port is not None:
262349
processes = [p for p in processes if p.port == port]
263350

@@ -288,7 +375,7 @@ def main(
288375
)
289376

290377
with Live(
291-
_render_processes_table(processes, selected),
378+
_render_processes_table(processes, selected, details),
292379
console=console,
293380
refresh_per_second=refresh_rate,
294381
) as live:
@@ -298,7 +385,9 @@ def main(
298385
is_filtering = False
299386
filter_text = ""
300387
processes = get_processes()
301-
live.update(_render_processes_table(processes, selected))
388+
live.update(
389+
_render_processes_table(processes, selected, details)
390+
)
302391
continue
303392
elif ch == key.UP or ch == "k":
304393
selected = max(0, selected - 1)
@@ -331,7 +420,8 @@ def main(
331420
border_style="magenta",
332421
)
333422
display = Group(
334-
filter_panel, _render_processes_table(processes, selected)
423+
filter_panel,
424+
_render_processes_table(processes, selected, details),
335425
)
336426
live.update(display)
337427
else:
@@ -348,7 +438,8 @@ def main(
348438
border_style="magenta",
349439
)
350440
display = Group(
351-
filter_panel, _render_processes_table(processes, selected)
441+
filter_panel,
442+
_render_processes_table(processes, selected, details),
352443
)
353444
live.update(display)
354445
continue # Skip the update at the end of the loop
@@ -359,7 +450,7 @@ def main(
359450
# Exit live context to show confirmation view
360451
process_to_kill = processes[selected]
361452
break
362-
live.update(_render_processes_table(processes, selected))
453+
live.update(_render_processes_table(processes, selected, details))
363454

364455
if process_to_kill is not None:
365456
_clear_screen()

pyproject.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ authors = [
77
{ name = "Savannah Ostrowski", email = "[email protected]" }
88
]
99
license = { text = "MIT" }
10-
requires-python = ">=3.12"
10+
requires-python = ">=3.13"
1111
dependencies = [
12+
"psutil>=7.1.2",
1213
"readchar>=4.2.1",
1314
"rich>=14.2.0",
1415
"typer>=0.15.1",
@@ -20,9 +21,10 @@ classifiers = [
2021
"Intended Audience :: Developers",
2122
"License :: OSI Approved :: MIT License",
2223
"Operating System :: MacOS",
24+
"Operating System :: Microsoft :: Windows",
2325
"Operating System :: POSIX :: Linux",
2426
"Programming Language :: Python :: 3",
25-
"Programming Language :: Python :: 3.12",
27+
"Programming Language :: Python :: 3.13",
2628
"Topic :: System :: Monitoring",
2729
"Topic :: Utilities",
2830
]
@@ -60,7 +62,13 @@ dev = [
6062
"ruff>=0.14.2",
6163
]
6264

65+
[tool.ruff]
66+
fix = true
67+
68+
[tool.ruff.lint]
69+
select = ["I"] # Enable isort rules for import sorting
70+
6371
[tool.pyright]
6472
include = ["gruyere"]
6573
typeCheckingMode = "strict"
66-
pythonVersion = "3.12"
74+
pythonVersion = "3.13"

0 commit comments

Comments
 (0)