kport 2.1.1__py3-none-any.whl → 3.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.
kport.py CHANGED
@@ -1,567 +1,1833 @@
1
- #!/usr/bin/env python3
2
- """
3
- kport - Cross-platform port inspector and killer
4
- A simple command-line tool to inspect and kill processes using specific ports
5
- """
6
- import argparse
7
- import os
8
- import platform
9
- import subprocess
10
- import sys
11
- import re
12
-
13
-
14
- # ANSI color codes
15
- class Colors:
16
- RED = '\033[91m'
17
- GREEN = '\033[92m'
18
- YELLOW = '\033[93m'
19
- BLUE = '\033[94m'
20
- MAGENTA = '\033[95m'
21
- CYAN = '\033[96m'
22
- WHITE = '\033[97m'
23
- BOLD = '\033[1m'
24
- RESET = '\033[0m'
25
-
26
-
27
- def colorize(text, color):
28
- """Add color to text if terminal supports it"""
29
- if platform.system() == "Windows":
30
- # Enable ANSI colors on Windows 10+
31
- os.system("")
32
- return f"{color}{text}{Colors.RESET}"
33
-
34
-
35
- def run(cmd):
36
- """Execute shell command and return output"""
37
- try:
38
- return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL)
39
- except subprocess.CalledProcessError:
40
- return ""
41
- except Exception as e:
42
- print(colorize(f"Error executing command: {e}", Colors.RED))
43
- return ""
44
-
45
-
46
- def validate_port(port):
47
- """Validate if port number is in valid range"""
48
- if not (1 <= port <= 65535):
49
- print(colorize(f"Error: Port {port} is not valid. Port must be between 1 and 65535.", Colors.RED))
50
- sys.exit(1)
51
-
52
-
53
- def parse_port_range(port_range):
54
- """Parse port range string (e.g., '3000-3010') into list of ports"""
55
- try:
56
- if '-' in port_range:
57
- start, end = port_range.split('-')
58
- start_port = int(start.strip())
59
- end_port = int(end.strip())
60
-
61
- if start_port > end_port:
62
- print(colorize(f"Error: Invalid range {port_range}. Start port must be less than end port.", Colors.RED))
63
- sys.exit(1)
64
-
65
- if end_port - start_port > 1000:
66
- print(colorize(f"Error: Range too large ({end_port - start_port} ports). Maximum 1000 ports allowed.", Colors.RED))
67
- sys.exit(1)
68
-
69
- ports = list(range(start_port, end_port + 1))
70
- for port in ports:
71
- validate_port(port)
72
- return ports
73
- else:
74
- port = int(port_range)
75
- validate_port(port)
76
- return [port]
77
- except ValueError:
78
- print(colorize(f"Error: Invalid port or range format: {port_range}", Colors.RED))
79
- sys.exit(1)
80
-
81
-
82
- def find_pid(port):
83
- """Find process ID using given port"""
84
- system = platform.system()
85
-
86
- if system == "Windows":
87
- out = run(f"netstat -ano | findstr :{port}")
88
- if not out:
89
- return None, None
90
-
91
- # Parse the first line (could be multiple connections)
92
- lines = out.strip().split('\n')
93
- for line in lines:
94
- parts = line.split()
95
- if len(parts) >= 5:
96
- pid = parts[-1]
97
- proc_info = run(f'tasklist /FI "PID eq {pid}" /FO LIST')
98
- return pid, proc_info
99
-
100
- return None, None
101
-
102
- else: # Linux, Ubuntu, macOS
103
- out = run(f"lsof -t -i:{port}")
104
- if not out:
105
- return None, None
106
-
107
- pid = out.strip().split('\n')[0] # Get first PID if multiple
108
- proc_info = run(f"ps -p {pid} -o pid,user,command")
109
- return pid, proc_info
110
-
111
-
112
- def list_all_ports():
113
- """List all listening ports and their processes"""
114
- system = platform.system()
115
-
116
- print(colorize("\n📋 Listing all active ports...\n", Colors.CYAN + Colors.BOLD))
117
-
118
- if system == "Windows":
119
- out = run("netstat -ano | findstr LISTENING")
120
- if not out:
121
- print(colorize("No listening ports found.", Colors.YELLOW))
122
- return
123
-
124
- print(colorize(f"{'Protocol':<10} {'Local Address':<25} {'State':<15} {'PID':<10}", Colors.BOLD))
125
- print("─" * 70)
126
-
127
- for line in out.strip().split('\n'):
128
- parts = line.split()
129
- if len(parts) >= 5:
130
- protocol = parts[0]
131
- local_addr = parts[1]
132
- state = parts[3]
133
- pid = parts[4]
134
- print(f"{protocol:<10} {local_addr:<25} {state:<15} {pid:<10}")
135
-
136
- else: # Linux, Ubuntu, macOS
137
- out = run("lsof -i -P -n | grep LISTEN")
138
- if not out:
139
- print(colorize("No listening ports found.", Colors.YELLOW))
140
- return
141
-
142
- print(colorize(f"{'Command':<20} {'PID':<10} {'User':<15} {'Address':<30}", Colors.BOLD))
143
- print("─" * 80)
144
-
145
- for line in out.strip().split('\n'):
146
- parts = line.split()
147
- if len(parts) >= 9:
148
- command = parts[0]
149
- pid = parts[1]
150
- user = parts[2]
151
- address = parts[8]
152
- print(f"{command:<20} {pid:<10} {user:<15} {address:<30}")
153
-
154
-
155
- def find_pids_by_process_name(process_name):
156
- """Find all PIDs matching a process name"""
157
- system = platform.system()
158
- pids = []
159
-
160
- if system == "Windows":
161
- out = run(f'tasklist /FI "IMAGENAME eq {process_name}*" /FO CSV /NH')
162
- if out:
163
- for line in out.strip().split('\n'):
164
- parts = line.replace('"', '').split(',')
165
- if len(parts) >= 2:
166
- try:
167
- pid = parts[1]
168
- pids.append(pid)
169
- except (ValueError, IndexError):
170
- continue
171
- else: # Linux, Ubuntu, macOS
172
- out = run(f"pgrep -f {process_name}")
173
- if out:
174
- pids = [pid.strip() for pid in out.strip().split('\n') if pid.strip()]
175
-
176
- return pids
177
-
178
-
179
- def get_process_name(pid):
180
- """Get process name from PID"""
181
- system = platform.system()
182
-
183
- if system == "Windows":
184
- out = run(f'tasklist /FI "PID eq {pid}" /FO CSV /NH')
185
- if out:
186
- parts = out.strip().split(',')[0].replace('"', '')
187
- return parts
188
- else:
189
- out = run(f"ps -p {pid} -o comm=")
190
- if out:
191
- return out.strip()
192
-
193
- return "Unknown"
194
-
195
-
196
- def find_ports_by_process_name(process_name):
197
- """Find all ports used by processes matching a name"""
198
- system = platform.system()
199
- process_port_map = [] # List of tuples: (pid, process_name, port)
200
-
201
- if system == "Windows":
202
- # Get all PIDs matching the process name
203
- pids = find_pids_by_process_name(process_name)
204
-
205
- if pids:
206
- # For each PID, find what ports it's using
207
- for pid in pids:
208
- out = run(f"netstat -ano | findstr {pid}")
209
- if out:
210
- proc_name = get_process_name(pid)
211
- ports_seen = set()
212
-
213
- for line in out.strip().split('\n'):
214
- parts = line.split()
215
- if len(parts) >= 5 and parts[-1] == pid:
216
- # Extract port from local address (format: IP:PORT)
217
- local_addr = parts[1]
218
- if ':' in local_addr:
219
- port = local_addr.split(':')[-1]
220
- if port not in ports_seen:
221
- ports_seen.add(port)
222
- state = parts[3] if len(parts) > 3 else "UNKNOWN"
223
- process_port_map.append((pid, proc_name, port, state))
224
-
225
- else: # Linux, Ubuntu, macOS
226
- # Use lsof to find ports by process name
227
- out = run(f"lsof -i -P -n | grep -i {process_name}")
228
- if out:
229
- for line in out.strip().split('\n'):
230
- parts = line.split()
231
- if len(parts) >= 9:
232
- command = parts[0]
233
- pid = parts[1]
234
- # Extract port from address (format: *:PORT or IP:PORT)
235
- address = parts[8]
236
- if ':' in address:
237
- port = address.split(':')[-1]
238
- # Filter out non-LISTEN states if needed
239
- state = "LISTEN" if "LISTEN" in line else "ESTABLISHED"
240
- process_port_map.append((pid, command, port, state))
241
-
242
- return process_port_map
243
-
244
-
245
- def kill_pid(pid, silent=False):
246
- """Kill process by PID"""
247
- system = platform.system()
248
-
249
- if system == "Windows":
250
- result = run(f"taskkill /PID {pid} /F")
251
- return result
252
- else:
253
- result = run(f"kill -9 {pid}")
254
- return result if result else "Process killed successfully"
255
-
256
-
257
- def main():
258
- """Main entry point"""
259
- parser = argparse.ArgumentParser(
260
- description="🔪 kport - Cross-platform port inspector and killer",
261
- epilog="Examples:\n"
262
- " kport -i 8080 Inspect port 8080\n"
263
- " kport -im 3000 3001 3002 Inspect multiple ports\n"
264
- " kport -ir 3000-3010 Inspect port range\n"
265
- " kport -ip node Inspect all processes matching 'node'\n"
266
- " kport -k 8080 Kill process using port 8080\n"
267
- " kport -ka 3000 3001 3002 Kill processes on multiple ports\n"
268
- " kport -kr 3000-3010 Kill processes on port range\n"
269
- " kport -kp node Kill all processes matching 'node'\n"
270
- " kport -l List all listening ports\n",
271
- formatter_class=argparse.RawDescriptionHelpFormatter
272
- )
273
- parser.add_argument("-i", "--inspect", type=int, metavar="PORT",
274
- help="Inspect which process is using the specified port")
275
- parser.add_argument("-im", "--inspect-multiple", type=int, nargs="+", metavar="PORT",
276
- help="Inspect multiple ports")
277
- parser.add_argument("-ir", "--inspect-range", type=str, metavar="RANGE",
278
- help="Inspect port range (e.g., 3000-3010)")
279
- parser.add_argument("-ip", "--inspect-process", type=str, metavar="NAME",
280
- help="Inspect all processes matching the given name")
281
- parser.add_argument("-k", "--kill", type=int, metavar="PORT",
282
- help="Kill the process using the specified port")
283
- parser.add_argument("-kp", "--kill-process", type=str, metavar="NAME",
284
- help="Kill all processes matching the given name")
285
- parser.add_argument("-ka", "--kill-all", type=int, nargs="+", metavar="PORT",
286
- help="Kill processes on multiple ports")
287
- parser.add_argument("-kr", "--kill-range", type=str, metavar="RANGE",
288
- help="Kill processes on port range (e.g., 3000-3010)")
289
- parser.add_argument("-l", "--list", action="store_true",
290
- help="List all listening ports and their processes")
291
- parser.add_argument("-v", "--version", action="version", version="kport 2.1.1")
292
-
293
- args = parser.parse_args()
294
-
295
- # If no arguments provided, show help
296
- if not (args.inspect or args.inspect_multiple or args.inspect_range or args.inspect_process or
297
- args.kill or args.list or args.kill_process or args.kill_all or args.kill_range):
298
- parser.print_help()
299
- sys.exit(0)
300
-
301
- if args.list:
302
- list_all_ports()
303
-
304
- if args.inspect_multiple:
305
- print(colorize(f"\n🔍 Inspecting {len(args.inspect_multiple)} port(s)...\n", Colors.CYAN + Colors.BOLD))
306
-
307
- results = []
308
- for port in args.inspect_multiple:
309
- validate_port(port)
310
- pid, info = find_pid(port)
311
- if pid:
312
- proc_name = get_process_name(pid)
313
- results.append((port, pid, proc_name))
314
-
315
- if not results:
316
- print(colorize(f"❌ No processes found on any of the specified ports", Colors.RED))
317
- else:
318
- print(colorize(f"{'Port':<10} {'PID':<10} {'Process':<30}", Colors.BOLD))
319
- print("─" * 60)
320
-
321
- for port, pid, proc_name in results:
322
- print(f"{colorize(str(port), Colors.CYAN):<19} {pid:<10} {proc_name:<30}")
323
-
324
- print(colorize(f"\n✓ Found processes on {len(results)}/{len(args.inspect_multiple)} port(s)", Colors.GREEN))
325
-
326
- if args.inspect_range:
327
- ports = parse_port_range(args.inspect_range)
328
- print(colorize(f"\n🔍 Inspecting port range {args.inspect_range} ({len(ports)} ports)...\n", Colors.CYAN + Colors.BOLD))
329
-
330
- results = []
331
- for port in ports:
332
- pid, info = find_pid(port)
333
- if pid:
334
- proc_name = get_process_name(pid)
335
- results.append((port, pid, proc_name))
336
-
337
- if not results:
338
- print(colorize(f"❌ No processes found in port range {args.inspect_range}", Colors.RED))
339
- else:
340
- print(colorize(f"{'Port':<10} {'PID':<10} {'Process':<30}", Colors.BOLD))
341
- print("─" * 60)
342
-
343
- for port, pid, proc_name in results:
344
- print(f"{colorize(str(port), Colors.CYAN):<19} {pid:<10} {proc_name:<30}")
345
-
346
- print(colorize(f"\n✓ Found processes on {len(results)}/{len(ports)} port(s) in range", Colors.GREEN))
347
-
348
- if args.inspect_process:
349
- print(colorize(f"\n🔍 Inspecting processes matching '{args.inspect_process}'...\n", Colors.CYAN + Colors.BOLD))
350
-
351
- process_info = find_ports_by_process_name(args.inspect_process)
352
-
353
- if not process_info:
354
- print(colorize(f"❌ No processes found matching '{args.inspect_process}'", Colors.RED))
355
- else:
356
- print(colorize(f"Found {len(process_info)} connection(s) for processes matching '{args.inspect_process}':\n", Colors.YELLOW))
357
-
358
- # Group by PID for better display
359
- pid_groups = {}
360
- for pid, proc_name, port, state in process_info:
361
- if pid not in pid_groups:
362
- pid_groups[pid] = {'name': proc_name, 'ports': []}
363
- pid_groups[pid]['ports'].append((port, state))
364
-
365
- print(colorize(f"{'PID':<10} {'Process':<25} {'Port':<10} {'State':<15}", Colors.BOLD))
366
- print("─" * 70)
367
-
368
- for pid, data in pid_groups.items():
369
- proc_name = data['name']
370
- ports = data['ports']
371
-
372
- # Print first port
373
- if ports:
374
- port, state = ports[0]
375
- print(f"{colorize(pid, Colors.CYAN):<19} {proc_name:<25} {port:<10} {state:<15}")
376
-
377
- # Print additional ports for same PID
378
- for port, state in ports[1:]:
379
- print(f"{'':<10} {'':<25} {port:<10} {state:<15}")
380
-
381
- print(colorize(f"\n✓ Total processes found: {len(pid_groups)}", Colors.GREEN))
382
- print(colorize(f"✓ Total connections: {len(process_info)}", Colors.GREEN))
383
-
384
- if args.kill_process:
385
- print(colorize(f"\n🔪 Killing all processes matching '{args.kill_process}'...\n", Colors.CYAN + Colors.BOLD))
386
-
387
- pids = find_pids_by_process_name(args.kill_process)
388
- if not pids:
389
- print(colorize(f"❌ No processes found matching '{args.kill_process}'", Colors.RED))
390
- else:
391
- print(colorize(f"Found {len(pids)} process(es) matching '{args.kill_process}':", Colors.YELLOW))
392
- print("─" * 50)
393
-
394
- for pid in pids:
395
- proc_name = get_process_name(pid)
396
- print(colorize(f" PID {pid}: {proc_name}", Colors.WHITE))
397
-
398
- # Ask for confirmation
399
- try:
400
- confirm = input(colorize(f"\nAre you sure you want to kill {len(pids)} process(es)? (y/N): ", Colors.MAGENTA))
401
- if confirm.lower() not in ['y', 'yes']:
402
- print(colorize("Operation cancelled.", Colors.YELLOW))
403
- sys.exit(0)
404
- except KeyboardInterrupt:
405
- print(colorize("\n\nOperation cancelled.", Colors.YELLOW))
406
- sys.exit(0)
407
-
408
- # Kill all processes
409
- killed_count = 0
410
- for pid in pids:
411
- result = kill_pid(pid, silent=True)
412
- if result or "SUCCESS" in str(result) or "killed" in str(result).lower():
413
- killed_count += 1
414
- print(colorize(f"✓ Killed PID {pid}", Colors.GREEN))
415
- else:
416
- print(colorize(f"✗ Failed to kill PID {pid}", Colors.RED))
417
-
418
- print(colorize(f"\n✓ Successfully killed {killed_count}/{len(pids)} process(es)", Colors.GREEN + Colors.BOLD))
419
-
420
- if args.kill_all:
421
- print(colorize(f"\n🔪 Killing processes on {len(args.kill_all)} port(s)...\n", Colors.CYAN + Colors.BOLD))
422
-
423
- # Validate all ports first
424
- for port in args.kill_all:
425
- validate_port(port)
426
-
427
- # Find all PIDs
428
- port_pid_map = {}
429
- for port in args.kill_all:
430
- pid, info = find_pid(port)
431
- if pid:
432
- port_pid_map[port] = (pid, info)
433
-
434
- if not port_pid_map:
435
- print(colorize(f"❌ No processes found on any of the specified ports", Colors.RED))
436
- else:
437
- print(colorize(f"Found processes on {len(port_pid_map)} port(s):", Colors.YELLOW))
438
- print("─" * 50)
439
-
440
- for port, (pid, info) in port_pid_map.items():
441
- proc_name = get_process_name(pid)
442
- print(colorize(f" Port {port}: PID {pid} ({proc_name})", Colors.WHITE))
443
-
444
- # Ask for confirmation
445
- try:
446
- confirm = input(colorize(f"\nAre you sure you want to kill {len(port_pid_map)} process(es)? (y/N): ", Colors.MAGENTA))
447
- if confirm.lower() not in ['y', 'yes']:
448
- print(colorize("Operation cancelled.", Colors.YELLOW))
449
- sys.exit(0)
450
- except KeyboardInterrupt:
451
- print(colorize("\n\nOperation cancelled.", Colors.YELLOW))
452
- sys.exit(0)
453
-
454
- # Kill all processes
455
- killed_count = 0
456
- for port, (pid, info) in port_pid_map.items():
457
- result = kill_pid(pid, silent=True)
458
- if result or "SUCCESS" in str(result) or "killed" in str(result).lower():
459
- killed_count += 1
460
- print(colorize(f"✓ Killed process on port {port} (PID {pid})", Colors.GREEN))
461
- else:
462
- print(colorize(f"✗ Failed to kill process on port {port} (PID {pid})", Colors.RED))
463
-
464
- print(colorize(f"\n✓ Successfully killed {killed_count}/{len(port_pid_map)} process(es)", Colors.GREEN + Colors.BOLD))
465
- print(colorize(f"Ports freed: {', '.join(map(str, port_pid_map.keys()))}", Colors.GREEN))
466
-
467
- if args.kill_range:
468
- ports = parse_port_range(args.kill_range)
469
- print(colorize(f"\n🔪 Killing processes on port range {args.kill_range} ({len(ports)} ports)...\n", Colors.CYAN + Colors.BOLD))
470
-
471
- # Find all PIDs in range
472
- port_pid_map = {}
473
- for port in ports:
474
- pid, info = find_pid(port)
475
- if pid:
476
- port_pid_map[port] = (pid, info)
477
-
478
- if not port_pid_map:
479
- print(colorize(f"❌ No processes found in port range {args.kill_range}", Colors.RED))
480
- else:
481
- print(colorize(f"Found processes on {len(port_pid_map)} port(s) in range:", Colors.YELLOW))
482
- print("─" * 50)
483
-
484
- for port, (pid, info) in port_pid_map.items():
485
- proc_name = get_process_name(pid)
486
- print(colorize(f" Port {port}: PID {pid} ({proc_name})", Colors.WHITE))
487
-
488
- # Ask for confirmation
489
- try:
490
- confirm = input(colorize(f"\nAre you sure you want to kill {len(port_pid_map)} process(es)? (y/N): ", Colors.MAGENTA))
491
- if confirm.lower() not in ['y', 'yes']:
492
- print(colorize("Operation cancelled.", Colors.YELLOW))
493
- sys.exit(0)
494
- except KeyboardInterrupt:
495
- print(colorize("\n\nOperation cancelled.", Colors.YELLOW))
496
- sys.exit(0)
497
-
498
- # Kill all processes
499
- killed_count = 0
500
- for port, (pid, info) in port_pid_map.items():
501
- result = kill_pid(pid, silent=True)
502
- if result or "SUCCESS" in str(result) or "killed" in str(result).lower():
503
- killed_count += 1
504
- print(colorize(f"✓ Killed process on port {port} (PID {pid})", Colors.GREEN))
505
- else:
506
- print(colorize(f"✗ Failed to kill process on port {port} (PID {pid})", Colors.RED))
507
-
508
- print(colorize(f"\n✓ Successfully killed {killed_count}/{len(port_pid_map)} process(es)", Colors.GREEN + Colors.BOLD))
509
- print(colorize(f"Ports freed: {', '.join(map(str, port_pid_map.keys()))}", Colors.GREEN))
510
-
511
- if args.inspect:
512
- validate_port(args.inspect)
513
- print(colorize(f"\n🔍 Inspecting port {args.inspect}...\n", Colors.CYAN + Colors.BOLD))
514
-
515
- pid, info = find_pid(args.inspect)
516
- if not pid:
517
- print(colorize(f"❌ No process found using port {args.inspect}", Colors.RED))
518
- else:
519
- print(colorize(f"✓ Port {args.inspect} is being used by PID {pid}", Colors.GREEN + Colors.BOLD))
520
- print(colorize("\nProcess Information:", Colors.YELLOW))
521
- print("─" * 50)
522
- print(info)
523
-
524
- if args.kill:
525
- validate_port(args.kill)
526
- print(colorize(f"\n🔪 Attempting to kill process on port {args.kill}...\n", Colors.CYAN + Colors.BOLD))
527
-
528
- pid, info = find_pid(args.kill)
529
- if not pid:
530
- print(colorize(f"❌ No process found using port {args.kill}", Colors.RED))
531
- else:
532
- print(colorize(f"Found PID {pid} using port {args.kill}", Colors.YELLOW))
533
-
534
- # Show process info before killing
535
- if info:
536
- print(colorize("\nProcess to be terminated:", Colors.YELLOW))
537
- print(info)
538
-
539
- # Ask for confirmation
540
- try:
541
- confirm = input(colorize("\nAre you sure you want to kill this process? (y/N): ", Colors.MAGENTA))
542
- if confirm.lower() not in ['y', 'yes']:
543
- print(colorize("Operation cancelled.", Colors.YELLOW))
544
- sys.exit(0)
545
- except KeyboardInterrupt:
546
- print(colorize("\n\nOperation cancelled.", Colors.YELLOW))
547
- sys.exit(0)
548
-
549
- result = kill_pid(pid)
550
- if result:
551
- print(colorize(f"\n✓ Successfully killed process {pid}", Colors.GREEN + Colors.BOLD))
552
- if "SUCCESS" in result or "killed" in result.lower():
553
- print(colorize(f"Port {args.kill} is now free.", Colors.GREEN))
554
- else:
555
- print(colorize(f"\n❌ Failed to kill process {pid}", Colors.RED))
556
- print(colorize("You may need administrator/sudo privileges.", Colors.YELLOW))
557
-
558
-
559
- if __name__ == "__main__":
560
- try:
561
- main()
562
- except KeyboardInterrupt:
563
- print(colorize("\n\nOperation cancelled by user.", Colors.YELLOW))
564
- sys.exit(0)
565
- except Exception as e:
566
- print(colorize(f"\nUnexpected error: {e}", Colors.RED))
567
- sys.exit(1)
1
+ #!/usr/bin/env python3
2
+ """
3
+ kport - Cross-platform port inspector and killer (upgraded)
4
+
5
+ Features:
6
+ - Uses psutil when available for reliable cross-platform behavior.
7
+ - Safe subprocess usage (no shell=True).
8
+ - Class-based inspector architecture: UnixInspector / WindowsInspector.
9
+ - JSON output (--json).
10
+ - Dry-run (--dry-run).
11
+ - Graceful kill (SIGTERM) then forced kill (SIGKILL) fallback with timeout.
12
+ - --exact matching for process names, --yes to skip confirmations.
13
+ - Port range parsing with limit (max 1000 ports).
14
+ - Dependency checks and helpful error messages.
15
+ - Exit codes: 0 OK, 1 general error, 2 invalid input, 3 permission denied, 4 port used by Docker, 5 port free.
16
+ - Friendly tables with color, and machine-readable JSON.
17
+
18
+ Usage examples:
19
+ kport.py -i 8080
20
+ kport.py -im 3000 3001 3002
21
+ kport.py -ir 3000-3010
22
+ kport.py -ip node --exact
23
+ kport.py -k 8080 --yes
24
+ kport.py -kp node --dry-run --json
25
+
26
+ # PRODUCT.md subcommand interface
27
+ kport.py inspect 8080 --json
28
+ kport.py explain 8080
29
+ kport.py kill 8080
30
+ kport.py list
31
+ kport.py docker
32
+ kport.py conflicts
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import platform
41
+ import re
42
+ import shutil
43
+ import signal
44
+ import subprocess
45
+ import sys
46
+ import time
47
+ from dataclasses import dataclass, asdict
48
+ from typing import List, Dict, Optional, Tuple, Any
49
+
50
+ # Try optional psutil for robust cross-platform behavior
51
+ try:
52
+ import psutil # type: ignore
53
+ USING_PSUTIL = True
54
+ except Exception:
55
+ USING_PSUTIL = False
56
+
57
+ # Colors (simple)
58
+ class Colors:
59
+ RED = '\033[91m'
60
+ GREEN = '\033[92m'
61
+ YELLOW = '\033[93m'
62
+ BLUE = '\033[94m'
63
+ MAGENTA = '\033[95m'
64
+ CYAN = '\033[96m'
65
+ WHITE = '\033[97m'
66
+ BOLD = '\033[1m'
67
+ RESET = '\033[0m'
68
+
69
+ def colorize(text: str, color: str) -> str:
70
+ # basic check for Windows ANSI support: modern terminals typically handle this
71
+ if platform.system() == "Windows":
72
+ # Attempt simple enable; no-op if unsupported
73
+ try:
74
+ os.system("") # enable ANSI processing in some terminals
75
+ except Exception:
76
+ pass
77
+ return f"{color}{text}{Colors.RESET}"
78
+
79
+ def check_dependency(cmd: str) -> bool:
80
+ """Return True if `cmd` is found on PATH."""
81
+ return shutil.which(cmd) is not None
82
+
83
+ # Exit codes
84
+ EXIT_OK = 0
85
+ EXIT_GENERAL_ERROR = 1
86
+ EXIT_INVALID_INPUT = 2
87
+ EXIT_PERMISSION = 3
88
+ EXIT_PORT_DOCKER = 4
89
+ EXIT_PORT_FREE = 5
90
+
91
+
92
+ def debug_log(enabled: bool, msg: str) -> None:
93
+ if enabled:
94
+ print(colorize(f"[debug] {msg}", Colors.BLUE), file=sys.stderr)
95
+
96
+
97
+ def _default_config_paths() -> List[str]:
98
+ home = os.path.expanduser("~")
99
+ return [
100
+ os.path.join(os.getcwd(), ".kport.json"),
101
+ os.path.join(home, ".kport.json"),
102
+ os.path.join(home, ".config", "kport", "config.json"),
103
+ ]
104
+
105
+
106
+ def load_config(config_path: Optional[str], debug: bool = False) -> Dict[str, Any]:
107
+ """Load JSON config file if present.
108
+
109
+ Config is optional; parse failures are treated as invalid input.
110
+ """
111
+ candidate_paths: List[str] = []
112
+ if config_path:
113
+ candidate_paths = [config_path]
114
+ else:
115
+ candidate_paths = _default_config_paths()
116
+
117
+ for path in candidate_paths:
118
+ if not path:
119
+ continue
120
+ path = os.path.expanduser(path)
121
+ if not os.path.exists(path):
122
+ continue
123
+ try:
124
+ with open(path, "r", encoding="utf-8") as f:
125
+ data = json.load(f)
126
+ if isinstance(data, dict):
127
+ debug_log(debug, f"Loaded config: {path}")
128
+ return data
129
+ debug_log(debug, f"Ignoring non-object config: {path}")
130
+ except json.JSONDecodeError as e:
131
+ print(colorize(f"Error: invalid JSON in config file {path}: {e}", Colors.RED), file=sys.stderr)
132
+ sys.exit(EXIT_INVALID_INPUT)
133
+ except Exception as e:
134
+ print(colorize(f"Error: failed to read config file {path}: {e}", Colors.RED), file=sys.stderr)
135
+ sys.exit(EXIT_INVALID_INPUT)
136
+ return {}
137
+
138
+
139
+ def apply_config_defaults(args: argparse.Namespace, cfg: Dict[str, Any]) -> None:
140
+ """Apply config as defaults (never overriding explicit CLI choices).
141
+
142
+ Supported keys:
143
+ - yes: bool
144
+ - dry_run: bool
145
+ - json: bool
146
+ - debug: bool
147
+ - force: bool
148
+ - graceful_timeout: number
149
+ - docker_action: "stop"|"restart"|"rm"
150
+ """
151
+ def _set_bool(name: str, key: str) -> None:
152
+ if hasattr(args, name) and getattr(args, name) is False and isinstance(cfg.get(key), bool):
153
+ setattr(args, name, cfg[key])
154
+
155
+ def _set_num(name: str, key: str) -> None:
156
+ if hasattr(args, name) and cfg.get(key) is not None:
157
+ try:
158
+ current = getattr(args, name)
159
+ # Only apply if still at argparse default
160
+ if name == "graceful_timeout" and float(current) == 3.0:
161
+ setattr(args, name, float(cfg[key]))
162
+ except Exception:
163
+ pass
164
+
165
+ _set_bool("yes", "yes")
166
+ _set_bool("dry_run", "dry_run")
167
+ _set_bool("json", "json")
168
+ _set_bool("debug", "debug")
169
+ _set_bool("force", "force")
170
+ _set_num("graceful_timeout", "graceful_timeout")
171
+
172
+ if hasattr(args, "docker_action") and getattr(args, "docker_action", None) is None:
173
+ v = cfg.get("docker_action")
174
+ if v in ("stop", "restart", "rm"):
175
+ setattr(args, "docker_action", v)
176
+
177
+ # Validation helpers
178
+ def validate_port(port: int) -> None:
179
+ if not (1 <= port <= 65535):
180
+ print(colorize(f"Error: Port {port} is not valid. Must be 1-65535.", Colors.RED), file=sys.stderr)
181
+ sys.exit(EXIT_INVALID_INPUT)
182
+
183
+ def parse_port_range(port_range: str, max_ports: int = 1000) -> List[int]:
184
+ """
185
+ Parse a port or range string:
186
+ - "8080" -> [8080]
187
+ - "3000-3010" -> [3000..3010] (limit enforced)
188
+ """
189
+ try:
190
+ if '-' in port_range:
191
+ start_s, end_s = port_range.split('-', 1)
192
+ start = int(start_s.strip())
193
+ end = int(end_s.strip())
194
+ if start > end:
195
+ print(colorize(f"Error: invalid range {port_range}: start > end", Colors.RED), file=sys.stderr)
196
+ sys.exit(EXIT_INVALID_INPUT)
197
+ total = end - start + 1
198
+ if total > max_ports:
199
+ print(colorize(f"Error: range too large ({total} ports). Maximum {max_ports} allowed.", Colors.RED), file=sys.stderr)
200
+ sys.exit(EXIT_INVALID_INPUT)
201
+ for p in (start, end):
202
+ validate_port(p)
203
+ return list(range(start, end + 1))
204
+ else:
205
+ port = int(port_range.strip())
206
+ validate_port(port)
207
+ return [port]
208
+ except ValueError:
209
+ print(colorize(f"Error: invalid port or range format: {port_range}", Colors.RED), file=sys.stderr)
210
+ sys.exit(EXIT_INVALID_INPUT)
211
+
212
+ @dataclass
213
+ class ProcessInfo:
214
+ pid: int
215
+ name: str
216
+ exe: Optional[str] = None
217
+ cmdline: Optional[List[str]] = None
218
+ user: Optional[str] = None
219
+
220
+ @dataclass
221
+ class PortBinding:
222
+ port: int
223
+ family: str
224
+ laddr: str
225
+ pid: Optional[int] = None
226
+ process_name: Optional[str] = None
227
+ state: Optional[str] = None
228
+
229
+
230
+ @dataclass
231
+ class DockerPortMapping:
232
+ container_id: str
233
+ container_name: str
234
+ image: str
235
+ status: str
236
+ host_ip: Optional[str]
237
+ host_port: int
238
+ container_port: int
239
+ proto: str
240
+
241
+
242
+ def _run_docker(args: List[str], debug: bool = False) -> subprocess.CompletedProcess:
243
+ debug_log(debug, f"docker {' '.join(args)}")
244
+ return subprocess.run(["docker", *args], capture_output=True, text=True)
245
+
246
+
247
+ def docker_available() -> bool:
248
+ return check_dependency("docker")
249
+
250
+
251
+ def list_docker_mappings(debug: bool = False) -> List[DockerPortMapping]:
252
+ """Return host-port mappings for running containers via `docker ps` + `docker port`.
253
+
254
+ This is intentionally CLI-based (no extra deps) and works on Linux/macOS/Windows
255
+ where Docker CLI is present.
256
+ """
257
+ if not docker_available():
258
+ return []
259
+
260
+ ps = _run_docker(["ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"], debug=debug)
261
+ if ps.returncode != 0:
262
+ debug_log(debug, f"docker ps failed: {ps.stderr.strip()}")
263
+ return []
264
+
265
+ mappings: List[DockerPortMapping] = []
266
+ for line in (ps.stdout or "").splitlines():
267
+ if not line.strip():
268
+ continue
269
+ parts = line.split("\t")
270
+ if len(parts) < 4:
271
+ continue
272
+ container_id, name, image, status = parts[0].strip(), parts[1].strip(), parts[2].strip(), parts[3].strip()
273
+ if not container_id:
274
+ continue
275
+
276
+ port_out = _run_docker(["port", container_id], debug=debug)
277
+ if port_out.returncode != 0:
278
+ debug_log(debug, f"docker port {container_id} failed: {port_out.stderr.strip()}")
279
+ continue
280
+ for pline in (port_out.stdout or "").splitlines():
281
+ # Example lines:
282
+ # 80/tcp -> 0.0.0.0:8080
283
+ # 80/tcp -> :::8080
284
+ pline = pline.strip()
285
+ if not pline or "->" not in pline or "/" not in pline:
286
+ continue
287
+
288
+ left, right = [p.strip() for p in pline.split("->", 1)]
289
+ # left: "80/tcp"
290
+ m = re.match(r"^(\d+)\/(tcp|udp)$", left)
291
+ if not m:
292
+ continue
293
+ container_port = int(m.group(1))
294
+ proto = m.group(2)
295
+
296
+ # right: "0.0.0.0:8080" or ":::8080" etc.
297
+ # Parse host port as last :<digits> segment.
298
+ host_ip: Optional[str] = None
299
+ host_port: Optional[int] = None
300
+ m2 = re.search(r":(\d+)$", right)
301
+ if not m2:
302
+ continue
303
+ try:
304
+ host_port = int(m2.group(1))
305
+ except Exception:
306
+ continue
307
+ host_ip = right[: right.rfind(":")].strip() or None
308
+
309
+ mappings.append(
310
+ DockerPortMapping(
311
+ container_id=container_id,
312
+ container_name=name,
313
+ image=image,
314
+ status=status,
315
+ host_ip=host_ip,
316
+ host_port=host_port,
317
+ container_port=container_port,
318
+ proto=proto,
319
+ )
320
+ )
321
+
322
+ # De-duplicate (Docker can return IPv4 + IPv6 lines for same mapping)
323
+ seen = set()
324
+ uniq: List[DockerPortMapping] = []
325
+ for m in mappings:
326
+ # docker often reports the same published port once for IPv4 (0.0.0.0)
327
+ # and once for IPv6 (:::) — treat those as the same mapping for display.
328
+ key = (m.container_id, m.host_port, m.container_port, m.proto)
329
+ if key in seen:
330
+ continue
331
+ seen.add(key)
332
+ uniq.append(m)
333
+
334
+ # Sort by host port, then name
335
+ return sorted(uniq, key=lambda x: (x.host_port, x.container_name))
336
+
337
+
338
+ def docker_mappings_for_host_port(port: int, debug: bool = False) -> List[DockerPortMapping]:
339
+ return [m for m in list_docker_mappings(debug=debug) if m.host_port == port]
340
+
341
+
342
+ def docker_action_on_container(container_id: str, action: str, dry_run: bool, debug: bool = False) -> Tuple[bool, str]:
343
+ if dry_run:
344
+ return True, f"Dry-run: would docker {action} {container_id}"
345
+ if action == "stop":
346
+ r = _run_docker(["stop", container_id], debug=debug)
347
+ elif action == "restart":
348
+ r = _run_docker(["restart", container_id], debug=debug)
349
+ elif action == "rm":
350
+ r = _run_docker(["rm", "-f", container_id], debug=debug)
351
+ else:
352
+ return False, f"Unknown docker action: {action}"
353
+ if r.returncode == 0:
354
+ return True, (r.stdout or "").strip() or f"docker {action} succeeded"
355
+ return False, (r.stderr or r.stdout or "").strip() or f"docker {action} failed"
356
+
357
+ # Base inspector
358
+ class BaseInspector:
359
+ def list_listening(self) -> List[PortBinding]:
360
+ raise NotImplementedError()
361
+
362
+ def find_pids_on_port(self, port: int) -> List[int]:
363
+ raise NotImplementedError()
364
+
365
+ def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
366
+ raise NotImplementedError()
367
+
368
+ def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
369
+ raise NotImplementedError()
370
+
371
+ def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
372
+ raise NotImplementedError()
373
+
374
+ def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
375
+ """
376
+ Attempt to kill process:
377
+ - Try graceful termination first (SIGTERM / terminate)
378
+ - Wait graceful_timeout seconds
379
+ - If still alive and force True, force kill (SIGKILL / taskkill / /F)
380
+ Returns (success, message)
381
+ """
382
+ raise NotImplementedError()
383
+
384
+ # psutil-based inspector (best behavior cross-platform)
385
+ class PsutilInspector(BaseInspector):
386
+ def list_listening(self) -> List[PortBinding]:
387
+ bindings: Dict[Tuple[int, str], PortBinding] = {}
388
+ # net_connections returns many entries; filter relevant ones
389
+ for conn in psutil.net_connections(kind='inet'):
390
+ # laddr may be empty for some connection types
391
+ if not conn.laddr:
392
+ continue
393
+ laddr = f"{conn.laddr.ip}:{conn.laddr.port}" if hasattr(conn.laddr, 'ip') else f"{conn.laddr[0]}:{conn.laddr[1]}"
394
+ port = conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1]
395
+ family = 'IPv6' if conn.family.name == 'AF_INET6' else 'IPv4'
396
+ state = conn.status
397
+ pid = conn.pid
398
+ key = (port, state)
399
+ proc_name = None
400
+ if pid:
401
+ try:
402
+ p = psutil.Process(pid)
403
+ proc_name = p.name()
404
+ except Exception:
405
+ proc_name = None
406
+ if (port, state) not in bindings:
407
+ bindings[(port, state)] = PortBinding(
408
+ port=port,
409
+ family=family,
410
+ laddr=laddr,
411
+ pid=pid,
412
+ process_name=proc_name,
413
+ state=state
414
+ )
415
+ # Return sorted by port
416
+ return sorted(bindings.values(), key=lambda b: b.port)
417
+
418
+ def find_pids_on_port(self, port: int) -> List[int]:
419
+ pids = set()
420
+ for conn in psutil.net_connections(kind='inet'):
421
+ if not conn.laddr:
422
+ continue
423
+ try:
424
+ conn_port = conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1]
425
+ except Exception:
426
+ continue
427
+ if conn_port == port and conn.pid:
428
+ pids.add(conn.pid)
429
+ return sorted(pids)
430
+
431
+ def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
432
+ try:
433
+ p = psutil.Process(pid)
434
+ return ProcessInfo(
435
+ pid=pid,
436
+ name=p.name(),
437
+ exe=p.exe() if p.exe() else None,
438
+ cmdline=p.cmdline() if p.cmdline() else None,
439
+ user=p.username() if hasattr(p, 'username') else None
440
+ )
441
+ except Exception:
442
+ return None
443
+
444
+ def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
445
+ out = []
446
+ name_lower = name.lower()
447
+ for p in psutil.process_iter(['pid', 'name', 'cmdline']):
448
+ try:
449
+ pname = (p.info['name'] or '')
450
+ if pname is None:
451
+ pname = ''
452
+ compare = pname.lower()
453
+ match = (compare == name_lower) if exact else (name_lower in compare or any(name_lower in (c or '').lower() for c in (p.info.get('cmdline') or [])))
454
+ if match:
455
+ out.append(p.info['pid'])
456
+ except Exception:
457
+ continue
458
+ return sorted(set(out))
459
+
460
+ def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
461
+ results: List[PortBinding] = []
462
+ name_lower = name.lower()
463
+ for conn in psutil.net_connections(kind='inet'):
464
+ if not conn.laddr:
465
+ continue
466
+ pid = conn.pid
467
+ if not pid:
468
+ continue
469
+ try:
470
+ p = psutil.Process(pid)
471
+ pname = (p.name() or '').lower()
472
+ cmdline = ' '.join(p.cmdline() or []).lower()
473
+ matched = (pname == name_lower) if exact else (name_lower in pname or name_lower in cmdline)
474
+ if matched:
475
+ laddr = f"{conn.laddr.ip}:{conn.laddr.port}" if hasattr(conn.laddr, 'ip') else f"{conn.laddr[0]}:{conn.laddr[1]}"
476
+ family = 'IPv6' if conn.family.name == 'AF_INET6' else 'IPv4'
477
+ results.append(PortBinding(
478
+ port=conn.laddr.port if hasattr(conn.laddr, 'port') else conn.laddr[1],
479
+ family=family,
480
+ laddr=laddr,
481
+ pid=pid,
482
+ process_name=p.name(),
483
+ state=conn.status
484
+ ))
485
+ except Exception:
486
+ continue
487
+ return sorted(results, key=lambda b: (b.pid or 0, b.port))
488
+
489
+ def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
490
+ try:
491
+ proc = psutil.Process(pid)
492
+ except psutil.NoSuchProcess:
493
+ return False, "No such process"
494
+ except Exception as e:
495
+ return False, f"Failed to access process: {e}"
496
+
497
+ if dry_run:
498
+ return True, "Dry-run: would terminate process"
499
+
500
+ try:
501
+ proc.terminate()
502
+ except psutil.AccessDenied:
503
+ return False, "Permission denied"
504
+ except Exception as e:
505
+ return False, f"Error terminating: {e}"
506
+
507
+ try:
508
+ proc.wait(timeout=graceful_timeout)
509
+ return True, "Terminated gracefully"
510
+ except psutil.TimeoutExpired:
511
+ if not force:
512
+ return False, "Still running after graceful timeout"
513
+ # force kill
514
+ try:
515
+ proc.kill()
516
+ proc.wait(timeout=2)
517
+ return True, "Killed (force)"
518
+ except psutil.NoSuchProcess:
519
+ return True, "Process disappeared after kill"
520
+ except psutil.AccessDenied:
521
+ return False, "Permission denied on force kill"
522
+ except Exception as e:
523
+ return False, f"Error on force kill: {e}"
524
+ except Exception as e:
525
+ return False, f"Error waiting for termination: {e}"
526
+
527
+ # Fallback inspector using shell utilities (safe subprocess calls without shell=True)
528
+ class FallbackInspector(BaseInspector):
529
+ def __init__(self):
530
+ self.system = platform.system()
531
+ self._ps_exe = None
532
+ if self.system == "Windows":
533
+ self._ps_exe = shutil.which("powershell") or shutil.which("pwsh")
534
+
535
+ def _powershell(self) -> Optional[str]:
536
+ return self._ps_exe
537
+
538
+ def _run_powershell_json(self, script: str) -> Optional[Any]:
539
+ ps = self._powershell()
540
+ if not ps:
541
+ return None
542
+ try:
543
+ cmd = [ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script]
544
+ proc = subprocess.run(cmd, capture_output=True, text=True)
545
+ if proc.returncode != 0:
546
+ return None
547
+ out = (proc.stdout or "").strip()
548
+ if not out:
549
+ return None
550
+ return json.loads(out)
551
+ except Exception:
552
+ return None
553
+
554
+ def list_listening(self) -> List[PortBinding]:
555
+ if self.system == "Windows":
556
+ return self._windows_listening()
557
+ else:
558
+ return self._unix_listening()
559
+
560
+ def _windows_listening(self) -> List[PortBinding]:
561
+ bindings: List[PortBinding] = []
562
+ # Prefer PowerShell (PRODUCT.md Phase 2) when available
563
+ ps_data = self._run_powershell_json(
564
+ "Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess,State | ConvertTo-Json -Depth 3"
565
+ )
566
+ if ps_data is not None:
567
+ items = ps_data if isinstance(ps_data, list) else [ps_data]
568
+ for it in items:
569
+ try:
570
+ port = int(it.get("LocalPort"))
571
+ except Exception:
572
+ continue
573
+ pid = None
574
+ try:
575
+ pid = int(it.get("OwningProcess"))
576
+ except Exception:
577
+ pid = None
578
+ laddr = f"{it.get('LocalAddress')}:{port}"
579
+ state = it.get("State")
580
+ pname = None
581
+ if pid:
582
+ info = self.get_process_info(pid)
583
+ pname = info.name if info else None
584
+ bindings.append(PortBinding(port=port, family='IPv4', laddr=laddr, pid=pid, process_name=pname, state=state))
585
+ return sorted(bindings, key=lambda b: b.port)
586
+
587
+ # Fallback to `netstat -ano` and `tasklist` for process names
588
+ if not check_dependency("netstat"):
589
+ print(colorize("Error: netstat not found on PATH.", Colors.RED), file=sys.stderr)
590
+ return bindings
591
+ try:
592
+ proc = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
593
+ lines = proc.stdout.splitlines()
594
+ for line in lines:
595
+ line = line.strip()
596
+ if not line:
597
+ continue
598
+ # Typical netstat line: TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 1234
599
+ parts = re.split(r'\s+', line)
600
+ if len(parts) >= 5 and parts[0].upper() in ("TCP", "UDP"):
601
+ proto = parts[0]
602
+ local_addr = parts[1]
603
+ state = parts[3] if len(parts) >= 5 else ""
604
+ pid = None
605
+ try:
606
+ pid = int(parts[-1])
607
+ except Exception:
608
+ pid = None
609
+ # Extract port if local_addr contains :
610
+ if ':' in local_addr:
611
+ port_str = local_addr.rsplit(':', 1)[-1]
612
+ try:
613
+ port = int(port_str)
614
+ except ValueError:
615
+ continue
616
+ pname = None
617
+ if pid:
618
+ info = self.get_process_info(pid)
619
+ pname = info.name if info else None
620
+ bindings.append(PortBinding(port=port, family='IPv4', laddr=local_addr, pid=pid, process_name=pname, state=state))
621
+ except Exception:
622
+ pass
623
+ return sorted(bindings, key=lambda b: b.port)
624
+
625
+ def _unix_listening(self) -> List[PortBinding]:
626
+ bindings: List[PortBinding] = []
627
+ # Prefer lsof if available
628
+ if check_dependency("lsof"):
629
+ try:
630
+ proc = subprocess.run(["lsof", "-i", "-P", "-n"], capture_output=True, text=True)
631
+ lines = proc.stdout.splitlines()
632
+ for line in lines:
633
+ if "LISTEN" not in line and "LISTENING" not in line:
634
+ continue
635
+ parts = re.split(r'\s+', line)
636
+ # Format often: COMMAND PID USER ... NAME
637
+ if len(parts) < 9:
638
+ continue
639
+ command = parts[0]
640
+ pid = None
641
+ user = parts[2] if len(parts) > 2 else None
642
+ try:
643
+ pid = int(parts[1])
644
+ except Exception:
645
+ pid = None
646
+ name_field = parts[8]
647
+ # address may be like *:8080 or 127.0.0.1:8080
648
+ if ':' in name_field:
649
+ port = None
650
+ try:
651
+ port = int(name_field.rsplit(':', 1)[-1])
652
+ except Exception:
653
+ continue
654
+ bindings.append(PortBinding(port=port, family='IPv4', laddr=name_field, pid=pid, process_name=command, state="LISTEN"))
655
+ except Exception:
656
+ pass
657
+ else:
658
+ # fallback to ss if available
659
+ if check_dependency("ss"):
660
+ try:
661
+ proc = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True)
662
+ lines = proc.stdout.splitlines()
663
+ # parse lines for LISTEN
664
+ for line in lines:
665
+ if "LISTEN" not in line:
666
+ continue
667
+ # example: LISTEN 0 128 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=1234,fd=3))
668
+ parts = re.split(r'\s+', line)
669
+ for token in parts:
670
+ if ':' in token and re.search(r':\d+$', token):
671
+ try:
672
+ port = int(token.rsplit(':', 1)[-1])
673
+ # pid parse from users:(("name",pid=1234,fd=3))
674
+ m = re.search(r'pid=(\d+)', line)
675
+ pid = int(m.group(1)) if m else None
676
+ pname = None
677
+ if pid:
678
+ info = self.get_process_info(pid)
679
+ pname = info.name if info else None
680
+ bindings.append(PortBinding(port=port, family='IPv4', laddr=token, pid=pid, process_name=pname, state="LISTEN"))
681
+ break
682
+ except Exception:
683
+ continue
684
+ except Exception:
685
+ pass
686
+ return sorted(bindings, key=lambda b: b.port)
687
+
688
+ def find_pids_on_port(self, port: int) -> List[int]:
689
+ if self.system == "Windows":
690
+ return self._windows_pids_on_port(port)
691
+ else:
692
+ return self._unix_pids_on_port(port)
693
+
694
+ def _windows_pids_on_port(self, port: int) -> List[int]:
695
+ pids = set()
696
+ # Prefer PowerShell
697
+ ps_data = self._run_powershell_json(
698
+ f"Get-NetTCPConnection -State Listen -LocalPort {port} | Select-Object -ExpandProperty OwningProcess | ConvertTo-Json -Depth 2"
699
+ )
700
+ if ps_data is not None:
701
+ if isinstance(ps_data, list):
702
+ for v in ps_data:
703
+ try:
704
+ pids.add(int(v))
705
+ except Exception:
706
+ continue
707
+ else:
708
+ try:
709
+ pids.add(int(ps_data))
710
+ except Exception:
711
+ pass
712
+ return sorted(pids)
713
+ if not check_dependency("netstat"):
714
+ return []
715
+ proc = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
716
+ for line in proc.stdout.splitlines():
717
+ parts = re.split(r'\s+', line.strip())
718
+ if len(parts) >= 5:
719
+ local_addr = parts[1]
720
+ if ':' in local_addr and local_addr.rsplit(':', 1)[-1] == str(port):
721
+ try:
722
+ pid = int(parts[-1])
723
+ pids.add(pid)
724
+ except Exception:
725
+ continue
726
+ return sorted(pids)
727
+
728
+ def _unix_pids_on_port(self, port: int) -> List[int]:
729
+ pids = set()
730
+ # Prefer lsof
731
+ if check_dependency("lsof"):
732
+ proc = subprocess.run(["lsof", "-t", "-i", f":{port}"], capture_output=True, text=True)
733
+ for line in proc.stdout.splitlines():
734
+ try:
735
+ pids.add(int(line.strip()))
736
+ except Exception:
737
+ continue
738
+ else:
739
+ # fallback to ss/grep netstat parsing
740
+ if check_dependency("ss"):
741
+ proc = subprocess.run(["ss", "-ltnp"], capture_output=True, text=True)
742
+ for line in proc.stdout.splitlines():
743
+ if f":{port} " in line or f":{port}\n" in line:
744
+ m = re.search(r'pid=(\d+)', line)
745
+ if m:
746
+ try:
747
+ pids.add(int(m.group(1)))
748
+ except Exception:
749
+ continue
750
+ return sorted(pids)
751
+
752
+ def get_process_info(self, pid: int) -> Optional[ProcessInfo]:
753
+ try:
754
+ if self.system == "Windows":
755
+ # Prefer PowerShell
756
+ ps_data = self._run_powershell_json(
757
+ f"Get-Process -Id {pid} | Select-Object Id,ProcessName,Path | ConvertTo-Json -Depth 3"
758
+ )
759
+ if isinstance(ps_data, dict):
760
+ name = ps_data.get("ProcessName")
761
+ exe = ps_data.get("Path")
762
+ if name:
763
+ return ProcessInfo(pid=pid, name=str(name), exe=str(exe) if exe else None)
764
+
765
+ # fallback: tasklist
766
+ proc = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True, text=True)
767
+ out = proc.stdout.strip()
768
+ if not out:
769
+ return None
770
+ # Format: "Image Name","PID","Session Name","Session#","Mem Usage"
771
+ parts = [p.strip().strip('"') for p in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', out)]
772
+ if parts:
773
+ name = parts[0]
774
+ return ProcessInfo(pid=pid, name=name)
775
+ else:
776
+ # Unix: use ps
777
+ proc = subprocess.run(["ps", "-p", str(pid), "-o", "pid=,comm=,user=,args="], capture_output=True, text=True)
778
+ out = proc.stdout.strip()
779
+ if not out:
780
+ return None
781
+ # Attempt parsing
782
+ parts = re.split(r'\s+', out, maxsplit=2)
783
+ if len(parts) >= 2:
784
+ name = parts[1]
785
+ user = parts[2].split()[0] if len(parts) >= 3 else None
786
+ return ProcessInfo(pid=pid, name=name, user=user)
787
+ except Exception:
788
+ return None
789
+ return None
790
+
791
+ def find_pids_by_name(self, name: str, exact: bool = False) -> List[int]:
792
+ if self.system == "Windows":
793
+ # tasklist with filter
794
+ # tasklist /FI "IMAGENAME eq name*" may be used; use tasklist and filter in python for robustness
795
+ proc = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True)
796
+ out = proc.stdout or ""
797
+ pids = []
798
+ name_lower = name.lower()
799
+ for line in out.splitlines():
800
+ parts = [p.strip().strip('"') for p in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', line)]
801
+ if len(parts) >= 2:
802
+ pname = parts[0]
803
+ pid_s = parts[1]
804
+ try:
805
+ pid = int(pid_s)
806
+ except Exception:
807
+ continue
808
+ match = (pname.lower() == name_lower) if exact else (name_lower in pname.lower())
809
+ if match:
810
+ pids.append(pid)
811
+ return sorted(pids)
812
+ else:
813
+ # use pgrep if available else ps/gawk
814
+ if check_dependency("pgrep"):
815
+ args = ["pgrep", "-f", name] if not exact else ["pgrep", "-x", name]
816
+ proc = subprocess.run(args, capture_output=True, text=True)
817
+ out = proc.stdout or ""
818
+ pids = []
819
+ for line in out.splitlines():
820
+ try:
821
+ pids.append(int(line.strip()))
822
+ except Exception:
823
+ continue
824
+ return sorted(pids)
825
+ else:
826
+ # fallback to ps -ef
827
+ proc = subprocess.run(["ps", "-ef"], capture_output=True, text=True)
828
+ out = proc.stdout or ""
829
+ pids = []
830
+ for line in out.splitlines():
831
+ if name in line if exact else name.lower() in line.lower():
832
+ parts = re.split(r'\s+', line.strip())
833
+ if len(parts) >= 2:
834
+ try:
835
+ pids.append(int(parts[1]))
836
+ except Exception:
837
+ continue
838
+ return sorted(set(pids))
839
+
840
+ def find_ports_by_process_name(self, name: str, exact: bool = False) -> List[PortBinding]:
841
+ results: List[PortBinding] = []
842
+ # Use lsof to map processes to ports if available
843
+ if check_dependency("lsof"):
844
+ try:
845
+ proc = subprocess.run(["lsof", "-i", "-P", "-n"], capture_output=True, text=True)
846
+ out = proc.stdout or ""
847
+ for line in out.splitlines():
848
+ if name.lower() not in line.lower() and (exact and name not in line):
849
+ continue
850
+ parts = re.split(r'\s+', line)
851
+ if len(parts) < 9:
852
+ continue
853
+ command = parts[0]
854
+ pid_s = parts[1]
855
+ try:
856
+ pid = int(pid_s)
857
+ except Exception:
858
+ pid = None
859
+ addr = parts[8]
860
+ # addr like *:8080
861
+ if ':' in addr:
862
+ try:
863
+ port = int(addr.rsplit(':', 1)[-1])
864
+ except Exception:
865
+ continue
866
+ results.append(PortBinding(port=port, family='IPv4', laddr=addr, pid=pid, process_name=command, state="LISTEN" if "LISTEN" in line else None))
867
+ except Exception:
868
+ pass
869
+ else:
870
+ # fallback: find pids then find their ports
871
+ pids = self.find_pids_by_name(name, exact)
872
+ for pid in pids:
873
+ # try lsof -p <pid> -i
874
+ if check_dependency("lsof"):
875
+ proc = subprocess.run(["lsof", "-a", "-p", str(pid), "-i", "-P", "-n"], capture_output=True, text=True)
876
+ out = proc.stdout or ""
877
+ for line in out.splitlines():
878
+ if "LISTEN" not in line and "TCP" not in line and "UDP" not in line:
879
+ continue
880
+ parts = re.split(r'\s+', line)
881
+ if len(parts) >= 9:
882
+ addr = parts[8]
883
+ try:
884
+ port = int(addr.rsplit(':', 1)[-1])
885
+ except Exception:
886
+ continue
887
+ results.append(PortBinding(port=port, family='IPv4', laddr=addr, pid=pid, process_name=parts[0], state="LISTEN"))
888
+ return sorted(results, key=lambda b: (b.pid or 0, b.port))
889
+
890
+ def kill_pid(self, pid: int, graceful_timeout: float = 3.0, force: bool = False, dry_run: bool = False) -> Tuple[bool, str]:
891
+ if dry_run:
892
+ return True, "Dry-run: would attempt terminate"
893
+ try:
894
+ if self.system == "Windows":
895
+ # taskkill without /F is "gentle", with /F is forced
896
+ try:
897
+ proc = subprocess.run(["taskkill", "/PID", str(pid)], capture_output=True, text=True)
898
+ if proc.returncode == 0:
899
+ return True, "Terminated (taskkill)"
900
+ except Exception:
901
+ pass
902
+ if force:
903
+ try:
904
+ proc = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True)
905
+ return (proc.returncode == 0), proc.stdout + proc.stderr
906
+ except Exception as e:
907
+ return False, f"Error taskkill: {e}"
908
+ return False, "Still running; taskkill gentle failed"
909
+ else:
910
+ # Unix: try SIGTERM then SIGKILL
911
+ try:
912
+ os.kill(pid, signal.SIGTERM)
913
+ except PermissionError:
914
+ return False, "Permission denied"
915
+ except ProcessLookupError:
916
+ return False, "No such process"
917
+ except Exception as e:
918
+ return False, f"Error sending SIGTERM: {e}"
919
+ # wait short time
920
+ waited = 0.0
921
+ interval = 0.1
922
+ while waited < graceful_timeout:
923
+ time.sleep(interval)
924
+ waited += interval
925
+ # check if process exists
926
+ try:
927
+ os.kill(pid, 0)
928
+ # still alive
929
+ except ProcessLookupError:
930
+ return True, "Terminated gracefully"
931
+ except PermissionError:
932
+ return False, "Permission denied"
933
+ if not force:
934
+ return False, "Still alive after graceful timeout"
935
+ # force kill
936
+ try:
937
+ os.kill(pid, signal.SIGKILL)
938
+ return True, "Killed (SIGKILL)"
939
+ except PermissionError:
940
+ return False, "Permission denied on SIGKILL"
941
+ except ProcessLookupError:
942
+ return True, "Process disappeared after SIGKILL"
943
+ except Exception as e:
944
+ return False, f"Error SIGKILL: {e}"
945
+ except Exception as e:
946
+ return False, f"Unexpected error: {e}"
947
+
948
+ # Factory
949
+ def get_inspector() -> BaseInspector:
950
+ if USING_PSUTIL:
951
+ return PsutilInspector()
952
+ else:
953
+ return FallbackInspector()
954
+
955
+ # CLI and orchestration
956
+ def print_table_listen(bindings: List[PortBinding]) -> None:
957
+ if not bindings:
958
+ print(colorize("No listening ports found.", Colors.YELLOW))
959
+ return
960
+ print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<25} {'State':<12} {'Address':<25}", Colors.BOLD))
961
+ print("─" * 80)
962
+ for b in bindings:
963
+ pid = str(b.pid) if b.pid is not None else "-"
964
+ pname = b.process_name or "-"
965
+ state = b.state or "-"
966
+ print(f"{colorize(str(b.port), Colors.CYAN):<8} {pid:<8} {pname:<25} {state:<12} {b.laddr:<25}")
967
+
968
+ def jsonify_bindings(bindings: List[PortBinding]) -> str:
969
+ return json.dumps([asdict(b) for b in bindings], indent=2)
970
+
971
+ def confirm_prompt(prompt: str, assume_yes: bool = False) -> bool:
972
+ if assume_yes:
973
+ return True
974
+ try:
975
+ resp = input(colorize(prompt + " (y/N): ", Colors.MAGENTA))
976
+ return resp.strip().lower() in ("y", "yes")
977
+ except KeyboardInterrupt:
978
+ print(colorize("\nOperation cancelled.", Colors.YELLOW))
979
+ sys.exit(EXIT_GENERAL_ERROR)
980
+
981
+
982
+ def choose_docker_action(assume_yes: bool) -> Optional[str]:
983
+ """Interactive docker action chooser. Returns action or None (cancel)."""
984
+ if assume_yes:
985
+ # safe default when user explicitly asked to skip prompts
986
+ return "stop"
987
+ print(colorize("\nChoose action:\n1) Stop container\n2) Restart container\n3) Remove container\n4) Cancel", Colors.CYAN))
988
+ try:
989
+ resp = input(colorize("Select (1-4): ", Colors.MAGENTA)).strip()
990
+ except KeyboardInterrupt:
991
+ print(colorize("\nOperation cancelled.", Colors.YELLOW))
992
+ return None
993
+ mapping = {"1": "stop", "2": "restart", "3": "rm", "4": None}
994
+ return mapping.get(resp)
995
+
996
+
997
+ def print_table_docker(mappings: List[DockerPortMapping]) -> None:
998
+ if not mappings:
999
+ print(colorize("No Docker-published ports found.", Colors.YELLOW))
1000
+ return
1001
+ print(colorize(f"{'PORT':<8} {'CONTAINER':<20} {'IMAGE':<25} {'STATUS':<20}", Colors.BOLD))
1002
+ print("─" * 80)
1003
+ for m in mappings:
1004
+ print(f"{colorize(str(m.host_port), Colors.CYAN):<8} {m.container_name:<20} {m.image:<25} {m.status:<20}")
1005
+
1006
+
1007
+ def print_table_list_product(local_bindings: List[PortBinding], docker_maps: List[DockerPortMapping]) -> None:
1008
+ """Product-style list output: PORT TYPE OWNER."""
1009
+ rows: Dict[int, Dict[str, Any]] = {}
1010
+ for b in local_bindings:
1011
+ rows.setdefault(b.port, {})
1012
+ rows[b.port]["local"] = b
1013
+ for d in docker_maps:
1014
+ rows.setdefault(d.host_port, {})
1015
+ rows[d.host_port]["docker"] = d
1016
+
1017
+ if not rows:
1018
+ print(colorize("No active ports found.", Colors.YELLOW))
1019
+ return
1020
+ print(colorize(f"{'PORT':<8} {'TYPE':<10} {'OWNER':<25}", Colors.BOLD))
1021
+ print("─" * 55)
1022
+ for port in sorted(rows.keys()):
1023
+ if "docker" in rows[port] and "local" in rows[port]:
1024
+ owner = (rows[port]["docker"].container_name)
1025
+ print(f"{colorize(str(port), Colors.CYAN):<8} {'conflict':<10} {owner:<25}")
1026
+ elif "docker" in rows[port]:
1027
+ owner = rows[port]["docker"].container_name
1028
+ print(f"{colorize(str(port), Colors.CYAN):<8} {'docker':<10} {owner:<25}")
1029
+ else:
1030
+ b = rows[port]["local"]
1031
+ owner = b.process_name or "-"
1032
+ print(f"{colorize(str(port), Colors.CYAN):<8} {'local':<10} {owner:<25}")
1033
+
1034
+
1035
+ def jsonify_docker(mappings: List[DockerPortMapping]) -> str:
1036
+ return json.dumps([asdict(m) for m in mappings], indent=2)
1037
+
1038
+
1039
+ def handle_product_command(args: argparse.Namespace, inspector: BaseInspector) -> int:
1040
+ """Implements PRODUCT.md `kport <command>` interface."""
1041
+ debug = bool(getattr(args, "debug", False))
1042
+
1043
+ if args.command == "docker":
1044
+ maps = list_docker_mappings(debug=debug)
1045
+ if args.json:
1046
+ print(jsonify_docker(maps))
1047
+ else:
1048
+ print_table_docker(maps)
1049
+ return EXIT_OK
1050
+
1051
+ if args.command == "list":
1052
+ local = inspector.list_listening()
1053
+ docker_maps = list_docker_mappings(debug=debug)
1054
+ if args.json:
1055
+ # Provide both sources; consumer can merge.
1056
+ print(json.dumps({"local": [asdict(b) for b in local], "docker": [asdict(m) for m in docker_maps]}, indent=2))
1057
+ else:
1058
+ print_table_list_product(local, docker_maps)
1059
+ return EXIT_OK
1060
+
1061
+ if args.command == "inspect":
1062
+ validate_port(args.port)
1063
+ local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
1064
+ docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
1065
+ pids = inspector.find_pids_on_port(args.port)
1066
+
1067
+ if docker_hits:
1068
+ m = docker_hits[0]
1069
+ payload = {
1070
+ "port": args.port,
1071
+ "type": "docker",
1072
+ "container": m.container_name,
1073
+ "image": m.image,
1074
+ "host_port": m.host_port,
1075
+ "container_port": m.container_port,
1076
+ "status": m.status,
1077
+ }
1078
+ if args.json:
1079
+ print(json.dumps(payload, indent=2))
1080
+ else:
1081
+ print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
1082
+ print("Type: Docker Container")
1083
+ print(f"Container: {m.container_name}")
1084
+ print(f"Image: {m.image}")
1085
+ print(f"Host Port: {m.host_port}")
1086
+ print(f"Container Port: {m.container_port}")
1087
+ print(f"Status: {m.status}")
1088
+ return EXIT_PORT_DOCKER
1089
+
1090
+ if not pids and not local_bindings:
1091
+ if args.json:
1092
+ print(json.dumps({"port": args.port, "type": "free"}, indent=2))
1093
+ else:
1094
+ print(colorize(f"Port {args.port} is free", Colors.GREEN))
1095
+ return EXIT_PORT_FREE
1096
+
1097
+ if not pids and local_bindings:
1098
+ # Port is listening, but OS did not provide PID (often needs elevated privileges)
1099
+ msg = "Port is in use, but the owning PID is not visible (try running with sudo/admin)."
1100
+ if args.json:
1101
+ print(json.dumps({"port": args.port, "type": "local-unknown", "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
1102
+ else:
1103
+ print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
1104
+ print("Type: Local Process")
1105
+ print(colorize(msg, Colors.YELLOW))
1106
+ return EXIT_OK
1107
+
1108
+ # local process
1109
+ info_list = []
1110
+ for pid in pids:
1111
+ info = inspector.get_process_info(pid)
1112
+ info_list.append({"pid": pid, "process": asdict(info) if info else None})
1113
+ if args.json:
1114
+ print(json.dumps({"port": args.port, "type": "local", "pids": info_list}, indent=2))
1115
+ else:
1116
+ print(colorize(f"Port: {args.port}", Colors.CYAN + Colors.BOLD))
1117
+ print("Type: Local Process")
1118
+ for entry in info_list:
1119
+ pid = entry["pid"]
1120
+ proc = entry["process"]
1121
+ if proc:
1122
+ print(f"PID: {pid}")
1123
+ print(f"Process: {proc.get('name')}")
1124
+ if proc.get("cmdline"):
1125
+ print(f"Command: {' '.join(proc['cmdline'])}")
1126
+ else:
1127
+ print(f"PID: {pid} (info unavailable)")
1128
+ return EXIT_OK
1129
+
1130
+ if args.command == "explain":
1131
+ validate_port(args.port)
1132
+ local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
1133
+ docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
1134
+ if docker_hits:
1135
+ m = docker_hits[0]
1136
+ if args.json:
1137
+ print(
1138
+ json.dumps(
1139
+ {
1140
+ "port": args.port,
1141
+ "blocked": True,
1142
+ "because": [
1143
+ f"It is mapped to Docker container '{m.container_name}'",
1144
+ f"Docker maps host port {m.host_port} → container port {m.container_port}",
1145
+ "The process runs inside an isolated network namespace",
1146
+ ],
1147
+ },
1148
+ indent=2,
1149
+ )
1150
+ )
1151
+ else:
1152
+ print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
1153
+ print(f"- It is mapped to Docker container \"{m.container_name}\"")
1154
+ print(f"- Docker maps host port {m.host_port} → container port {m.container_port}")
1155
+ print("- The process runs inside an isolated network namespace")
1156
+ return EXIT_PORT_DOCKER
1157
+
1158
+ pids = inspector.find_pids_on_port(args.port)
1159
+ if not pids and not local_bindings:
1160
+ if args.json:
1161
+ print(json.dumps({"port": args.port, "blocked": False}, indent=2))
1162
+ else:
1163
+ print(colorize(f"Port {args.port} is free", Colors.GREEN))
1164
+ return EXIT_PORT_FREE
1165
+
1166
+ if not pids and local_bindings:
1167
+ if args.json:
1168
+ print(json.dumps({"port": args.port, "blocked": True, "type": "local-unknown", "message": "Owning PID not visible (try sudo/admin)", "bindings": [asdict(b) for b in local_bindings]}, indent=2))
1169
+ else:
1170
+ print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
1171
+ print("- A local process is listening, but the owning PID is not visible")
1172
+ print("- This is commonly due to missing privileges; try running with sudo")
1173
+ return EXIT_OK
1174
+
1175
+ # local process explanation
1176
+ infos = []
1177
+ for pid in pids:
1178
+ info = inspector.get_process_info(pid)
1179
+ infos.append({"pid": pid, "process": asdict(info) if info else None})
1180
+ if args.json:
1181
+ print(json.dumps({"port": args.port, "blocked": True, "type": "local", "pids": infos}, indent=2))
1182
+ else:
1183
+ print(colorize(f"Port {args.port} is unavailable because:", Colors.YELLOW + Colors.BOLD))
1184
+ for entry in infos:
1185
+ proc = entry["process"]
1186
+ if proc:
1187
+ print(f"- PID {entry['pid']} ({proc.get('name')}) is listening")
1188
+ else:
1189
+ print(f"- PID {entry['pid']} is listening")
1190
+ return EXIT_OK
1191
+
1192
+ if args.command == "kill":
1193
+ validate_port(args.port)
1194
+ debug = bool(getattr(args, "debug", False))
1195
+ local_bindings = [b for b in inspector.list_listening() if b.port == args.port]
1196
+ docker_hits = docker_mappings_for_host_port(args.port, debug=debug)
1197
+ if docker_hits:
1198
+ m = docker_hits[0]
1199
+ action = getattr(args, "docker_action", None)
1200
+ if not action and not args.json:
1201
+ print(colorize(f"Port {args.port} belongs to Docker container: {m.container_name}", Colors.YELLOW + Colors.BOLD))
1202
+ action = choose_docker_action(assume_yes=args.yes)
1203
+ if not action:
1204
+ if args.json:
1205
+ print(
1206
+ json.dumps(
1207
+ {
1208
+ "port": args.port,
1209
+ "type": "docker",
1210
+ "container": m.container_name,
1211
+ "container_id": m.container_id,
1212
+ "available_actions": ["stop", "restart", "rm"],
1213
+ "performed": None,
1214
+ "message": "No action selected",
1215
+ },
1216
+ indent=2,
1217
+ )
1218
+ )
1219
+ else:
1220
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1221
+ return EXIT_GENERAL_ERROR
1222
+
1223
+ if args.json and not args.yes and not args.dry_run:
1224
+ print(
1225
+ json.dumps(
1226
+ {
1227
+ "port": args.port,
1228
+ "type": "docker",
1229
+ "container": m.container_name,
1230
+ "container_id": m.container_id,
1231
+ "requested_action": action,
1232
+ "performed": False,
1233
+ "message": "Refusing to act without --yes in JSON mode",
1234
+ },
1235
+ indent=2,
1236
+ )
1237
+ )
1238
+ return EXIT_GENERAL_ERROR
1239
+
1240
+ ok, msg = docker_action_on_container(m.container_id, action=action, dry_run=args.dry_run, debug=debug)
1241
+ if args.json:
1242
+ print(
1243
+ json.dumps(
1244
+ {
1245
+ "port": args.port,
1246
+ "type": "docker",
1247
+ "container": m.container_name,
1248
+ "container_id": m.container_id,
1249
+ "action": action,
1250
+ "ok": ok,
1251
+ "message": msg,
1252
+ },
1253
+ indent=2,
1254
+ )
1255
+ )
1256
+ else:
1257
+ if ok:
1258
+ print(colorize(f"✓ {msg}", Colors.GREEN))
1259
+ else:
1260
+ print(colorize(f"✗ {msg}", Colors.RED))
1261
+ return EXIT_OK if ok else EXIT_GENERAL_ERROR
1262
+
1263
+ # local process kill
1264
+ pids = inspector.find_pids_on_port(args.port)
1265
+ if not pids and not local_bindings:
1266
+ if args.json:
1267
+ print(json.dumps({"port": args.port, "killed": [], "failed": [], "message": "Port free"}, indent=2))
1268
+ else:
1269
+ print(colorize(f"Port {args.port} is free", Colors.GREEN))
1270
+ return EXIT_PORT_FREE
1271
+
1272
+ if not pids and local_bindings:
1273
+ msg = "Port is in use but PID is not visible; cannot kill safely without PID. Try sudo/admin."
1274
+ if args.json:
1275
+ print(json.dumps({"port": args.port, "ok": False, "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
1276
+ else:
1277
+ print(colorize(msg, Colors.RED))
1278
+ return EXIT_PERMISSION
1279
+
1280
+ if not args.json:
1281
+ print(colorize("Action plan:\n1. Send SIGTERM\n2. Wait\n3. Escalate if needed", Colors.CYAN))
1282
+ if not confirm_prompt("Proceed?", assume_yes=args.yes):
1283
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1284
+ return EXIT_GENERAL_ERROR
1285
+
1286
+ out_killed = []
1287
+ out_failed = []
1288
+ for pid in pids:
1289
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1290
+ if ok:
1291
+ out_killed.append({"pid": pid, "msg": msg})
1292
+ else:
1293
+ out_failed.append({"pid": pid, "msg": msg})
1294
+ if args.json:
1295
+ print(json.dumps({"port": args.port, "killed": out_killed, "failed": out_failed}, indent=2))
1296
+ else:
1297
+ for k in out_killed:
1298
+ print(colorize(f"✓ Killed PID {k['pid']} ({k['msg']})", Colors.GREEN))
1299
+ for f in out_failed:
1300
+ print(colorize(f"✗ Failed PID {f['pid']} ({f['msg']})", Colors.RED))
1301
+ return EXIT_OK if not out_failed else EXIT_GENERAL_ERROR
1302
+
1303
+ if args.command == "kill-process":
1304
+ pname = args.name
1305
+ pids = inspector.find_pids_by_name(pname, exact=args.exact)
1306
+ if not pids:
1307
+ if args.json:
1308
+ print(json.dumps({"name": pname, "pids": []}, indent=2))
1309
+ else:
1310
+ print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
1311
+ return EXIT_OK
1312
+ if not args.json and not confirm_prompt(f"Proceed to terminate {len(pids)} process(es)?", assume_yes=args.yes):
1313
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1314
+ return EXIT_GENERAL_ERROR
1315
+ killed = []
1316
+ failed = []
1317
+ for pid in pids:
1318
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1319
+ if ok:
1320
+ killed.append({"pid": pid, "msg": msg})
1321
+ else:
1322
+ failed.append({"pid": pid, "msg": msg})
1323
+ if args.json:
1324
+ print(json.dumps({"killed": killed, "failed": failed}, indent=2))
1325
+ else:
1326
+ for k in killed:
1327
+ print(colorize(f"✓ Killed PID {k['pid']} ({k['msg']})", Colors.GREEN))
1328
+ for f in failed:
1329
+ print(colorize(f"✗ Failed PID {f['pid']} ({f['msg']})", Colors.RED))
1330
+ return EXIT_OK if not failed else EXIT_GENERAL_ERROR
1331
+
1332
+ if args.command == "conflicts":
1333
+ docker_maps = list_docker_mappings(debug=debug)
1334
+ conflicts: List[Dict[str, Any]] = []
1335
+ for m in docker_maps:
1336
+ pids = inspector.find_pids_on_port(m.host_port)
1337
+ # Ignore the common docker-proxy holder; conflict means some *other* local process also binds.
1338
+ non_docker_pids = []
1339
+ for pid in pids:
1340
+ info = inspector.get_process_info(pid)
1341
+ pname = (info.name if info else "").lower()
1342
+ if "docker-proxy" in pname or pname.startswith("docker"):
1343
+ continue
1344
+ non_docker_pids.append({"pid": pid, "process": asdict(info) if info else None})
1345
+ if non_docker_pids:
1346
+ conflicts.append(
1347
+ {
1348
+ "port": m.host_port,
1349
+ "docker": asdict(m),
1350
+ "local": non_docker_pids,
1351
+ }
1352
+ )
1353
+ if args.json:
1354
+ print(json.dumps(conflicts, indent=2))
1355
+ else:
1356
+ if not conflicts:
1357
+ print(colorize("No port conflicts detected.", Colors.GREEN))
1358
+ else:
1359
+ print(colorize("WARNING: Port conflict detected", Colors.YELLOW + Colors.BOLD))
1360
+ for c in conflicts:
1361
+ print(f"\nPort: {c['port']}")
1362
+ print(f"- Docker container: {c['docker']['container_name']}")
1363
+ for lp in c["local"]:
1364
+ proc = lp.get("process") or {}
1365
+ print(f"- Local process: {proc.get('name') or 'Unknown'}")
1366
+ return EXIT_OK
1367
+
1368
+ return EXIT_INVALID_INPUT
1369
+
1370
+ def main(argv: Optional[List[str]] = None) -> int:
1371
+ parser = argparse.ArgumentParser(
1372
+ description="kport - Cross-platform port inspector and killer (upgraded)",
1373
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1374
+ epilog="""
1375
+ Examples:
1376
+ kport.py -i 8080
1377
+ kport.py -im 3000 3001 3002
1378
+ kport.py -ir 3000-3010
1379
+ kport.py -ip node --exact
1380
+ kport.py -k 8080 --yes
1381
+ kport.py -kp node --dry-run --json
1382
+ """
1383
+ )
1384
+
1385
+ # Global options (PRODUCT.md)
1386
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
1387
+ parser.add_argument("--dry-run", action="store_true", help="Show actions without executing")
1388
+ parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts")
1389
+ parser.add_argument("--debug", action="store_true", help="Verbose internal logs")
1390
+ parser.add_argument("--config", type=str, default=None, help="Path to JSON config file (default: .kport.json or ~/.config/kport/config.json)")
1391
+
1392
+ # Legacy flags (backward compatible)
1393
+ parser.add_argument("-i", "--inspect", type=int, metavar="PORT", help="Inspect which process is using the specified port")
1394
+ parser.add_argument("-im", "--inspect-multiple", type=int, nargs="+", metavar="PORT", help="Inspect multiple ports")
1395
+ parser.add_argument("-ir", "--inspect-range", type=str, metavar="RANGE", help="Inspect port range (e.g., 3000-3010)")
1396
+ parser.add_argument("-ip", "--inspect-process", type=str, metavar="NAME", help="Inspect all processes matching the given name")
1397
+ parser.add_argument("-k", "--kill", type=int, metavar="PORT", help="Kill the process(es) using the specified port")
1398
+ parser.add_argument("-kp", "--kill-process", type=str, metavar="NAME", help="Kill all processes matching the given name")
1399
+ parser.add_argument("-ka", "--kill-all", type=int, nargs="+", metavar="PORT", help="Kill processes on multiple ports")
1400
+ parser.add_argument("-kr", "--kill-range", type=str, metavar="RANGE", help="Kill processes on port range (e.g., 3000-3010)")
1401
+ parser.add_argument("-l", "--list", action="store_true", help="List all listening ports and their processes")
1402
+ parser.add_argument("--exact", action="store_true", help="Use exact match for process name lookups")
1403
+ parser.add_argument("--force", action="store_true", help="Force kill immediately if needed (after graceful timeout)")
1404
+ parser.add_argument("--graceful-timeout", type=float, default=3.0, help="Seconds to wait for graceful termination before forcing (default 3.0)")
1405
+ parser.add_argument("-v", "--version", action="version", version="kport 3.1.0")
1406
+
1407
+ # PRODUCT.md subcommands
1408
+ sub = parser.add_subparsers(dest="command")
1409
+ sp_inspect = sub.add_parser("inspect", help="Inspect a port (docker-aware)")
1410
+ sp_inspect.add_argument("port", type=int)
1411
+ sp_inspect.add_argument("--json", action="store_true")
1412
+ sp_inspect.add_argument("--debug", action="store_true")
1413
+ sp_inspect.add_argument("--config", type=str, default=None)
1414
+
1415
+ sp_explain = sub.add_parser("explain", help="Explain why a port is blocked")
1416
+ sp_explain.add_argument("port", type=int)
1417
+ sp_explain.add_argument("--json", action="store_true")
1418
+ sp_explain.add_argument("--debug", action="store_true")
1419
+ sp_explain.add_argument("--config", type=str, default=None)
1420
+
1421
+ sp_kill = sub.add_parser("kill", help="Safely free a port (docker-aware)")
1422
+ sp_kill.add_argument("port", type=int)
1423
+ sp_kill.add_argument("--docker-action", choices=["stop", "restart", "rm"], help="Action when port belongs to Docker")
1424
+ sp_kill.add_argument("--json", action="store_true")
1425
+ sp_kill.add_argument("--dry-run", action="store_true")
1426
+ sp_kill.add_argument("-y", "--yes", action="store_true")
1427
+ sp_kill.add_argument("--debug", action="store_true")
1428
+ sp_kill.add_argument("--force", action="store_true")
1429
+ sp_kill.add_argument("--graceful-timeout", type=float, default=3.0)
1430
+ sp_kill.add_argument("--config", type=str, default=None)
1431
+
1432
+ sp_kp = sub.add_parser("kill-process", help="Kill processes by name")
1433
+ sp_kp.add_argument("name", type=str)
1434
+ sp_kp.add_argument("--exact", action="store_true")
1435
+ sp_kp.add_argument("--json", action="store_true")
1436
+ sp_kp.add_argument("--dry-run", action="store_true")
1437
+ sp_kp.add_argument("-y", "--yes", action="store_true")
1438
+ sp_kp.add_argument("--debug", action="store_true")
1439
+ sp_kp.add_argument("--force", action="store_true")
1440
+ sp_kp.add_argument("--graceful-timeout", type=float, default=3.0)
1441
+ sp_kp.add_argument("--config", type=str, default=None)
1442
+
1443
+ sp_list = sub.add_parser("list", help="List active ports (local + docker)")
1444
+ sp_list.add_argument("--json", action="store_true")
1445
+ sp_list.add_argument("--debug", action="store_true")
1446
+ sp_list.add_argument("--config", type=str, default=None)
1447
+
1448
+ sp_docker = sub.add_parser("docker", help="List Docker-published ports")
1449
+ sp_docker.add_argument("--json", action="store_true")
1450
+ sp_docker.add_argument("--debug", action="store_true")
1451
+ sp_docker.add_argument("--config", type=str, default=None)
1452
+
1453
+ sp_conflicts = sub.add_parser("conflicts", help="Detect docker/local port conflicts")
1454
+ sp_conflicts.add_argument("--json", action="store_true")
1455
+ sp_conflicts.add_argument("--debug", action="store_true")
1456
+ sp_conflicts.add_argument("--config", type=str, default=None)
1457
+
1458
+ args = parser.parse_args(argv)
1459
+
1460
+ # Apply config defaults (if any)
1461
+ cfg = load_config(getattr(args, "config", None), debug=getattr(args, "debug", False))
1462
+ apply_config_defaults(args, cfg)
1463
+
1464
+ inspector = get_inspector()
1465
+
1466
+ # Convenience: if psutil not installed, show helpful hint once
1467
+ if not USING_PSUTIL:
1468
+ if not args.json:
1469
+ print(colorize("Notice: psutil not installed; falling back to system commands. Installing psutil improves reliability.", Colors.YELLOW))
1470
+
1471
+ try:
1472
+ # PRODUCT.md command mode
1473
+ if getattr(args, "command", None):
1474
+ return handle_product_command(args, inspector)
1475
+
1476
+ # No args => show help
1477
+ if not any([args.inspect, args.inspect_multiple, args.inspect_range, args.inspect_process, args.kill, args.list, args.kill_process, args.kill_all, args.kill_range]):
1478
+ parser.print_help()
1479
+ return EXIT_OK
1480
+
1481
+ # List all listening ports
1482
+ if args.list:
1483
+ bindings = inspector.list_listening()
1484
+ if args.json:
1485
+ print(jsonify_bindings(bindings))
1486
+ else:
1487
+ print(colorize("\n📋 Listening ports\n", Colors.CYAN + Colors.BOLD))
1488
+ print_table_listen(bindings)
1489
+
1490
+ # Inspect single port (legacy) - Docker-aware fallback
1491
+ if args.inspect:
1492
+ validate_port(args.inspect)
1493
+ local_bindings = [b for b in inspector.list_listening() if b.port == args.inspect]
1494
+ docker_hits = docker_mappings_for_host_port(args.inspect, debug=args.debug)
1495
+ pids = inspector.find_pids_on_port(args.inspect)
1496
+ if not pids:
1497
+ if docker_hits:
1498
+ m = docker_hits[0]
1499
+ if args.json:
1500
+ print(
1501
+ json.dumps(
1502
+ {
1503
+ "port": args.inspect,
1504
+ "type": "docker",
1505
+ "container": m.container_name,
1506
+ "image": m.image,
1507
+ "host_port": m.host_port,
1508
+ "container_port": m.container_port,
1509
+ "status": m.status,
1510
+ },
1511
+ indent=2,
1512
+ )
1513
+ )
1514
+ else:
1515
+ print(colorize(f"\n🐳 Port {args.inspect} is mapped to Docker container: {m.container_name}\n", Colors.GREEN + Colors.BOLD))
1516
+ print(f"Image: {m.image}")
1517
+ print(f"Host Port: {m.host_port} → Container Port: {m.container_port}/{m.proto}")
1518
+ print(f"Status: {m.status}")
1519
+ elif local_bindings:
1520
+ msg = "Port is in use, but the owning PID is not visible (try running with sudo/admin)."
1521
+ if args.json:
1522
+ print(json.dumps({"port": args.inspect, "type": "local-unknown", "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
1523
+ else:
1524
+ print(colorize("⚠ " + msg, Colors.YELLOW))
1525
+ else:
1526
+ msg = f"No processes found using port {args.inspect}"
1527
+ if args.json:
1528
+ print(json.dumps({"port": args.inspect, "pids": []}))
1529
+ else:
1530
+ print(colorize("❌ " + msg, Colors.RED))
1531
+ else:
1532
+ info_list = []
1533
+ for pid in pids:
1534
+ info = inspector.get_process_info(pid)
1535
+ info_list.append({"pid": pid, "process": asdict(info) if info else None})
1536
+ if args.json:
1537
+ out: Dict[str, Any] = {"port": args.inspect, "pids": info_list}
1538
+ if docker_hits:
1539
+ out["docker"] = [asdict(m) for m in docker_hits]
1540
+ print(json.dumps(out, indent=2))
1541
+ else:
1542
+ print(colorize(f"\n🔍 Port {args.inspect} is used by PID(s): {', '.join(map(str,pids))}\n", Colors.GREEN + Colors.BOLD))
1543
+ if docker_hits:
1544
+ m = docker_hits[0]
1545
+ print(colorize(f"🐳 Docker mapping: {m.container_name} ({m.image}) host {m.host_port} → {m.container_port}/{m.proto}", Colors.CYAN))
1546
+ for entry in info_list:
1547
+ pid = entry["pid"]
1548
+ proc = entry["process"]
1549
+ if proc:
1550
+ print(colorize(f"PID {pid}: {proc['name']} (user={proc.get('user')})", Colors.WHITE))
1551
+ if proc.get('cmdline'):
1552
+ print(f" cmd: {' '.join(proc['cmdline'])}")
1553
+ else:
1554
+ print(colorize(f"PID {pid}: info unavailable", Colors.YELLOW))
1555
+
1556
+ # Inspect multiple ports
1557
+ if args.inspect_multiple:
1558
+ ports = args.inspect_multiple
1559
+ results = []
1560
+ for port in ports:
1561
+ validate_port(port)
1562
+ pids = inspector.find_pids_on_port(port)
1563
+ for pid in pids:
1564
+ proc = inspector.get_process_info(pid)
1565
+ results.append({"port": port, "pid": pid, "process": asdict(proc) if proc else None})
1566
+ if args.json:
1567
+ print(json.dumps(results, indent=2))
1568
+ else:
1569
+ print(colorize(f"\n🔍 Inspecting {len(ports)} port(s)...\n", Colors.CYAN + Colors.BOLD))
1570
+ if not results:
1571
+ print(colorize("❌ No processes found on any of the specified ports", Colors.RED))
1572
+ else:
1573
+ print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<30}", Colors.BOLD))
1574
+ print("─" * 60)
1575
+ for r in results:
1576
+ pname = r['process']['name'] if r['process'] else "-"
1577
+ print(f"{colorize(str(r['port']), Colors.CYAN):<8} {str(r['pid']):<8} {pname:<30}")
1578
+ print(colorize(f"\n✓ Found processes on {len(results)} items", Colors.GREEN))
1579
+
1580
+ # Inspect range
1581
+ if args.inspect_range:
1582
+ ports = parse_port_range(args.inspect_range)
1583
+ results = []
1584
+ for port in ports:
1585
+ pids = inspector.find_pids_on_port(port)
1586
+ for pid in pids:
1587
+ proc = inspector.get_process_info(pid)
1588
+ results.append({"port": port, "pid": pid, "process": asdict(proc) if proc else None})
1589
+ if args.json:
1590
+ print(json.dumps(results, indent=2))
1591
+ else:
1592
+ print(colorize(f"\n🔍 Inspecting port range {args.inspect_range} ({len(ports)} ports)...\n", Colors.CYAN + Colors.BOLD))
1593
+ if not results:
1594
+ print(colorize(f"❌ No processes found in port range {args.inspect_range}", Colors.RED))
1595
+ else:
1596
+ print(colorize(f"{'Port':<8} {'PID':<8} {'Process':<30}", Colors.BOLD))
1597
+ print("─" * 60)
1598
+ for r in results:
1599
+ pname = r['process']['name'] if r['process'] else "-"
1600
+ print(f"{colorize(str(r['port']), Colors.CYAN):<8} {str(r['pid']):<8} {pname:<30}")
1601
+ print(colorize(f"\n✓ Found processes on {len(results)} entries", Colors.GREEN))
1602
+
1603
+ # Inspect by process name
1604
+ if args.inspect_process:
1605
+ pname = args.inspect_process
1606
+ bindings = inspector.find_ports_by_process_name(pname, exact=args.exact)
1607
+ if args.json:
1608
+ print(jsonify_bindings(bindings))
1609
+ else:
1610
+ print(colorize(f"\n🔍 Inspecting processes matching '{pname}'\n", Colors.CYAN + Colors.BOLD))
1611
+ if not bindings:
1612
+ print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
1613
+ else:
1614
+ pid_groups: Dict[int, List[PortBinding]] = {}
1615
+ for b in bindings:
1616
+ pid_groups.setdefault(b.pid or 0, []).append(b)
1617
+ print(colorize(f"{'PID':<8} {'Process':<25} {'Port':<8} {'State':<12}", Colors.BOLD))
1618
+ print("─" * 70)
1619
+ for pid, ports in pid_groups.items():
1620
+ proc_name = ports[0].process_name or "-"
1621
+ print(f"{colorize(str(pid), Colors.CYAN):<8} {proc_name:<25} {ports[0].port:<8} {ports[0].state or '-':<12}")
1622
+ for p in ports[1:]:
1623
+ print(f"{'':<8} {'':<25} {p.port:<8} {p.state or '-':<12}")
1624
+ print(colorize(f"\n✓ Total processes found: {len(pid_groups)}", Colors.GREEN))
1625
+ print(colorize(f"✓ Total connections: {len(bindings)}", Colors.GREEN))
1626
+
1627
+ # Kill by process name
1628
+ if args.kill_process:
1629
+ pname = args.kill_process
1630
+ pids = inspector.find_pids_by_name(pname, exact=args.exact)
1631
+ if not pids:
1632
+ if args.json:
1633
+ print(json.dumps({"name": pname, "pids": []}, indent=2))
1634
+ else:
1635
+ print(colorize(f"❌ No processes found matching '{pname}'", Colors.RED))
1636
+ else:
1637
+ if args.json:
1638
+ # In JSON mode, we won't prompt for confirmation; user should opt --yes if they want auto-approval in scripts.
1639
+ out = []
1640
+ for pid in pids:
1641
+ info = inspector.get_process_info(pid)
1642
+ out.append({"pid": pid, "process": asdict(info) if info else None})
1643
+ print(json.dumps({"name": pname, "pids": out}, indent=2))
1644
+ if not args.yes:
1645
+ print(colorize("Note: JSON output provided. Use --yes to actually perform kills.", Colors.YELLOW))
1646
+ else:
1647
+ # proceed to kill
1648
+ killed = []
1649
+ failed = []
1650
+ for pid in pids:
1651
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1652
+ if ok:
1653
+ killed.append({"pid": pid, "msg": msg})
1654
+ else:
1655
+ failed.append({"pid": pid, "msg": msg})
1656
+ print(json.dumps({"killed": killed, "failed": failed}, indent=2))
1657
+ else:
1658
+ print(colorize(f"Found {len(pids)} process(es) matching '{pname}':", Colors.YELLOW))
1659
+ for pid in pids:
1660
+ info = inspector.get_process_info(pid)
1661
+ display = f"PID {pid}: {info.name if info else 'Unknown'}"
1662
+ print(colorize(" " + display, Colors.WHITE))
1663
+ if not confirm_prompt(f"\nAre you sure you want to kill {len(pids)} process(es)?", assume_yes=args.yes):
1664
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1665
+ else:
1666
+ killed_count = 0
1667
+ for pid in pids:
1668
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1669
+ if ok:
1670
+ killed_count += 1
1671
+ print(colorize(f"✓ Killed PID {pid} ({msg})", Colors.GREEN))
1672
+ else:
1673
+ print(colorize(f"✗ Failed to kill PID {pid} ({msg})", Colors.RED))
1674
+ print(colorize(f"\n✓ Successfully killed {killed_count}/{len(pids)} process(es)", Colors.GREEN + Colors.BOLD))
1675
+
1676
+ # Kill single port (legacy) - Docker-aware fallback
1677
+ if args.kill:
1678
+ validate_port(args.kill)
1679
+ local_bindings = [b for b in inspector.list_listening() if b.port == args.kill]
1680
+ docker_hits = docker_mappings_for_host_port(args.kill, debug=args.debug)
1681
+ pids = inspector.find_pids_on_port(args.kill)
1682
+ if not pids:
1683
+ if docker_hits:
1684
+ m = docker_hits[0]
1685
+ if args.json and not args.yes and not args.dry_run:
1686
+ print(
1687
+ json.dumps(
1688
+ {
1689
+ "port": args.kill,
1690
+ "type": "docker",
1691
+ "container": m.container_name,
1692
+ "container_id": m.container_id,
1693
+ "message": "Refusing to act without --yes in JSON mode",
1694
+ },
1695
+ indent=2,
1696
+ )
1697
+ )
1698
+ else:
1699
+ if not args.json:
1700
+ print(colorize(f"\n🐳 Port {args.kill} belongs to Docker container: {m.container_name}", Colors.YELLOW + Colors.BOLD))
1701
+ action = choose_docker_action(assume_yes=args.yes)
1702
+ else:
1703
+ action = "stop"
1704
+ if action:
1705
+ ok, msg = docker_action_on_container(m.container_id, action=action, dry_run=args.dry_run, debug=args.debug)
1706
+ if args.json:
1707
+ print(json.dumps({"port": args.kill, "type": "docker", "action": action, "ok": ok, "message": msg}, indent=2))
1708
+ else:
1709
+ print(colorize(("✓ " if ok else "✗ ") + msg, Colors.GREEN if ok else Colors.RED))
1710
+ elif local_bindings:
1711
+ msg = "Port is in use but PID is not visible; cannot kill safely. Try sudo/admin."
1712
+ if args.json:
1713
+ print(json.dumps({"port": args.kill, "ok": False, "message": msg, "bindings": [asdict(b) for b in local_bindings]}, indent=2))
1714
+ else:
1715
+ print(colorize(msg, Colors.RED))
1716
+ else:
1717
+ if args.json:
1718
+ print(json.dumps({"port": args.kill, "killed": [], "failed": []}, indent=2))
1719
+ else:
1720
+ print(colorize(f"❌ No process found using port {args.kill}", Colors.RED))
1721
+ else:
1722
+ if args.json:
1723
+ out_killed = []
1724
+ out_failed = []
1725
+ for pid in pids:
1726
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1727
+ if ok:
1728
+ out_killed.append({"pid": pid, "msg": msg})
1729
+ else:
1730
+ out_failed.append({"pid": pid, "msg": msg})
1731
+ out: Dict[str, Any] = {"port": args.kill, "killed": out_killed, "failed": out_failed}
1732
+ if docker_hits:
1733
+ out["docker"] = [asdict(m) for m in docker_hits]
1734
+ print(json.dumps(out, indent=2))
1735
+ else:
1736
+ print(colorize(f"Found PID(s) {', '.join(map(str,pids))} using port {args.kill}", Colors.YELLOW))
1737
+ if docker_hits:
1738
+ m = docker_hits[0]
1739
+ print(colorize(f"🐳 Docker mapping: {m.container_name} ({m.image}) host {m.host_port} → {m.container_port}/{m.proto}", Colors.CYAN))
1740
+ for pid in pids:
1741
+ info = inspector.get_process_info(pid)
1742
+ if info:
1743
+ print(colorize(f"\nProcess to be terminated: PID {pid} - {info.name}", Colors.YELLOW))
1744
+ if info.cmdline:
1745
+ print(" cmd:", ' '.join(info.cmdline))
1746
+ if not confirm_prompt("\nAre you sure you want to kill this process(es)?", assume_yes=args.yes):
1747
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1748
+ else:
1749
+ killed_count = 0
1750
+ for pid in pids:
1751
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1752
+ if ok:
1753
+ killed_count += 1
1754
+ print(colorize(f"✓ Killed PID {pid} ({msg})", Colors.GREEN))
1755
+ else:
1756
+ print(colorize(f"✗ Failed to kill PID {pid} ({msg})", Colors.RED))
1757
+ print(colorize(f"\n✓ Successfully killed {killed_count}/{len(pids)} process(es)", Colors.GREEN + Colors.BOLD))
1758
+
1759
+ # Kill multiple ports list
1760
+ if args.kill_all:
1761
+ for port in args.kill_all:
1762
+ validate_port(port)
1763
+ port_pid_map: Dict[int, List[int]] = {}
1764
+ for port in args.kill_all:
1765
+ pids = inspector.find_pids_on_port(port)
1766
+ if pids:
1767
+ port_pid_map[port] = pids
1768
+ if not port_pid_map:
1769
+ print(colorize("❌ No processes found on any of the specified ports", Colors.RED))
1770
+ else:
1771
+ print(colorize("Found processes on the following ports:", Colors.YELLOW))
1772
+ for port, pids in port_pid_map.items():
1773
+ names = [inspector.get_process_info(pid).name if inspector.get_process_info(pid) else "?" for pid in pids]
1774
+ print(colorize(f" Port {port}: PIDs {', '.join(map(str,pids))} ({', '.join(names)})", Colors.WHITE))
1775
+ if not confirm_prompt(f"\nAre you sure you want to kill {sum(len(ps) for ps in port_pid_map.values())} process(es)?", assume_yes=args.yes):
1776
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1777
+ else:
1778
+ killed_count = 0
1779
+ total = sum(len(ps) for ps in port_pid_map.values())
1780
+ for port, pids in port_pid_map.items():
1781
+ for pid in pids:
1782
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1783
+ if ok:
1784
+ killed_count += 1
1785
+ print(colorize(f"✓ Killed PID {pid} (port {port})", Colors.GREEN))
1786
+ else:
1787
+ print(colorize(f"✗ Failed to kill PID {pid} (port {port}): {msg}", Colors.RED))
1788
+ print(colorize(f"\n✓ Successfully killed {killed_count}/{total} process(es)", Colors.GREEN + Colors.BOLD))
1789
+
1790
+ # Kill range
1791
+ if args.kill_range:
1792
+ ports = parse_port_range(args.kill_range)
1793
+ port_pid_map = {}
1794
+ for port in ports:
1795
+ pids = inspector.find_pids_on_port(port)
1796
+ if pids:
1797
+ port_pid_map[port] = pids
1798
+ if not port_pid_map:
1799
+ print(colorize(f"❌ No processes found in port range {args.kill_range}", Colors.RED))
1800
+ else:
1801
+ print(colorize(f"Found processes on {len(port_pid_map)} port(s) in range:", Colors.YELLOW))
1802
+ for port, pids in port_pid_map.items():
1803
+ print(colorize(f" Port {port}: PIDs {', '.join(map(str,pids))}", Colors.WHITE))
1804
+ if not confirm_prompt(f"\nAre you sure you want to kill {sum(len(ps) for ps in port_pid_map.values())} process(es)?", assume_yes=args.yes):
1805
+ print(colorize("Operation cancelled.", Colors.YELLOW))
1806
+ else:
1807
+ killed_count = 0
1808
+ total = sum(len(ps) for ps in port_pid_map.values())
1809
+ for port, pids in port_pid_map.items():
1810
+ for pid in pids:
1811
+ ok, msg = inspector.kill_pid(pid, graceful_timeout=args.graceful_timeout, force=args.force, dry_run=args.dry_run)
1812
+ if ok:
1813
+ killed_count += 1
1814
+ print(colorize(f"✓ Killed PID {pid} (port {port})", Colors.GREEN))
1815
+ else:
1816
+ print(colorize(f"✗ Failed to kill PID {pid} (port {port}): {msg}", Colors.RED))
1817
+ print(colorize(f"\n✓ Successfully killed {killed_count}/{total} process(es)", Colors.GREEN + Colors.BOLD))
1818
+
1819
+ except PermissionError:
1820
+ print(colorize("Permission denied. Try running with elevated privileges (sudo / admin).", Colors.RED), file=sys.stderr)
1821
+ return EXIT_PERMISSION
1822
+ except KeyboardInterrupt:
1823
+ print(colorize("\nOperation cancelled by user.", Colors.YELLOW))
1824
+ return EXIT_GENERAL_ERROR
1825
+ except Exception as e:
1826
+ print(colorize(f"Unexpected error: {e}", Colors.RED), file=sys.stderr)
1827
+ return EXIT_GENERAL_ERROR
1828
+
1829
+ return EXIT_OK
1830
+
1831
+ if __name__ == "__main__":
1832
+ rc = main()
1833
+ sys.exit(rc)