Skip to content

Commit 6cf38f4

Browse files
committed
feat(ports): add shimkit ports — cross-platform port owner + killer
First of three new tools for v0.3.0 (ports → hosts → ssh per docs/plans/v0.3.0-new-tools.md). Smallest of the three; gets the manager + commands + parser pattern right for the larger two. Surface: - `shimkit ports show [PORT]` — list listening TCP/UDP sockets via `lsof` on macOS or `ss` on Linux. `--json` emits a typed Event; positional PORT narrows the result. - `shimkit ports kill PORT` — signal the holder(s) with a MODERATE prompt (`--yes` / `--force` / refused under `--no-input`). Allowed signals TERM/KILL/INT/HUP; anything else exits 1. PIDs below `tools.ports.system_pid_threshold` (default 100) require the severe-tier `--confirm KILL-INIT` token; pid 1 is refused at any token. Internals: - Pure parsers in `owners.py` for both `lsof -F pcnuP` and `ss -tulnpH`. Manager owns the CommandRunner shell-out; parsers are unit-testable without a subprocess. - PortsConfig added to the typed config (system_pid_threshold, init_pid_severe_token, default_signal) with defaults in config/defaults.json. Tests: 19 cases (252 total) — parser fixtures for both formats, platform-gate exit 69, `lsof`/`ss`-missing exit 69, --json shape, narrowed-port path, system-tier refusal, severe-token override, dry-run no-side-effect, disallowed-signal rejection. Gates: ruff clean, mypy strict clean. No new optional extras.
1 parent 770a482 commit 6cf38f4

13 files changed

Lines changed: 1247 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- `shimkit ports` — cross-platform TCP/UDP port owner inspector +
12+
killer. `shimkit ports show [PORT]` lists every listening socket
13+
via `lsof` on macOS or `ss` on Linux; `shimkit ports kill PORT`
14+
signals the holder(s) with a MODERATE prompt. Allowed signals
15+
`TERM/KILL/INT/HUP`; system-tier PIDs (below
16+
`tools.ports.system_pid_threshold`, default 100) require the
17+
severe-tier `--confirm KILL-INIT` token. No new optional extras —
18+
uses `CommandRunner` only. Adds 19 tests (parser fixtures for
19+
both `lsof -F pcnuP` and `ss -tulnpH` output, plus CLI/manager
20+
coverage). [`docs/tools/ports.md`](docs/tools/ports.md).
21+
922
## [0.2.3] — 2026-05-14
1023

1124
### Removed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ behaviour: [`docs/installation.md`](docs/installation.md).
4141
- **[`shimkit docker-clean`](docs/tools/docker-clean.md)** — Docker
4242
resource cleanup (Linux + macOS + WSL). Status, quick, prune-*, nuke,
4343
schedule-snippet emit.
44+
- **[`shimkit ports`](docs/tools/ports.md)** — list / kill the process
45+
holding a TCP or UDP port (macOS + Linux). `lsof` on macOS, `ss` on
46+
Linux. MODERATE prompt on `kill`; severe token for system-tier PIDs.
4447

4548
Plus three utilities:
4649

docs/plans/v0.3.0-new-tools.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# v0.3.0 plan — three new tools (`ports`, `hosts`, `ssh`)
2+
3+
> Scope per maintainer (2026-05-14): "add all of the above:
4+
> shimkit {ssh, hosts, ports, etc}". Charter is the host-machine
5+
> dev-workflow envelope; each of these fits.
6+
7+
## Ordering
8+
9+
Build in increasing complexity so the architecture decisions get
10+
exercised by the simplest tool first:
11+
12+
1. **`shimkit ports`** (smallest, ~150 LOC manager + ~30 tests)
13+
2. **`shimkit hosts`** (medium, ~250 LOC manager + ~40 tests; reuses
14+
atomic-write + backup from `adguard`)
15+
3. **`shimkit ssh`** (largest, ~400 LOC manager + ~60 tests; pulls
16+
together everything from both)
17+
18+
Each tool lands as its own commit on `main`; ship v0.3.0 once all
19+
three are in.
20+
21+
## Architecture rules — same five from CONTRIBUTING.md
22+
23+
1. All subprocess via `CommandRunner.run(...)` (argv-list, no `shell=True`).
24+
2. All output via `UI.*` — no `print`, no `typer.echo`.
25+
3. Config-driven values (defaults.json) for anything user-tunable.
26+
4. Builder pattern: `Manager.create().boot().run()`.
27+
5. Fluent `self` returns from builders.
28+
29+
## Per-tool surface
30+
31+
### `shimkit ports` — port owner inspector + killer
32+
33+
```text
34+
shimkit ports # interactive menu (Manager.run)
35+
shimkit ports show [PORT] # cross-platform: lsof on macOS, ss on Linux
36+
shimkit ports kill PORT # MODERATE prompt; --yes/--force; --signal=TERM|KILL
37+
```
38+
39+
- **Cross-platform**: `lsof -nP -iTCP -sTCP:LISTEN` on macOS,
40+
`ss -tulnp` on Linux. Output normalised to a `PortOwner` model.
41+
- **JSON output**: `--json` emits `{status, data: [{port, proto, pid, name, user}]}`.
42+
- **MODERATE prompt** on `kill`. SEVERE prompt only if `pid == 1` or
43+
`name == "systemd"` (refuse with `--confirm KILL-INIT`).
44+
- **No extra deps** — uses `CommandRunner.run` only. No `psutil`.
45+
46+
### `shimkit hosts``/etc/hosts` editor with backup
47+
48+
```text
49+
shimkit hosts # interactive menu
50+
shimkit hosts show [--json]
51+
shimkit hosts add IP NAME # MODERATE prompt; atomic write
52+
shimkit hosts remove NAME # MODERATE prompt
53+
shimkit hosts block DOMAIN # add 127.0.0.1 entry
54+
shimkit hosts unblock DOMAIN
55+
shimkit hosts apply-list URL_OR_PATH # SEVERE; --confirm APPLY-LIST
56+
shimkit hosts rollback # restore latest /etc/hosts.bak-*
57+
```
58+
59+
- **Atomic write**: parse → mutate in-memory → `install -m 644` to a
60+
temp file → atomic move (reuse pattern from
61+
`adguard/resolv.py::write_resolv_static`).
62+
- **Backup pattern**: `/etc/hosts.bak-YYYYMMDD-HHMMSS` (same as
63+
`adguard`).
64+
- **Block list parser**: tolerant of comment lines + the StevenBlack
65+
format (`0.0.0.0 example.com`). Caps applied entries per call
66+
(configurable; default 5000).
67+
- **Needs root** for any mutator; exits 77 with the
68+
`sudo shimkit hosts ...` hint matching `adguard`.
69+
70+
### `shimkit ssh` — key + agent + perms hygiene
71+
72+
```text
73+
shimkit ssh # interactive menu
74+
shimkit ssh keys list [--json]
75+
shimkit ssh keys generate NAME # ed25519; MODERATE prompt; passphrase via stdin
76+
shimkit ssh keys rotate NAME # generate new + load agent + print update steps
77+
shimkit ssh agent status [--json]
78+
shimkit ssh agent start # idempotent ssh-agent boot
79+
shimkit ssh agent add KEY_PATH # ssh-add wrapper
80+
shimkit ssh known-hosts audit [--json] # find duplicates + stale entries
81+
shimkit ssh known-hosts prune # MODERATE prompt; remove duplicates
82+
shimkit ssh perms audit [--json] # check ~/.ssh and key file modes
83+
shimkit ssh perms fix # MODERATE prompt; chmod 700/600/644
84+
shimkit ssh config show [HOST] # parse ~/.ssh/config and show Host blocks
85+
```
86+
87+
- **No extra deps**`ssh-keygen`, `ssh-add`, `ssh-agent`, and
88+
`ssh-keyscan` are all baseline.
89+
- **Passphrase handling**: prompt via Typer's `hide_input=True`; never
90+
log; ssh-keygen consumes via `-N` (validated by length, not
91+
content-pattern, so the value never hits the redaction layer).
92+
- **Perm matrix** lives in config (`tools.ssh.perms`):
93+
- `~/.ssh` → 700
94+
- `~/.ssh/config`, `known_hosts`, `authorized_keys` → 644
95+
- Private keys (no `.pub` suffix) → 600
96+
- Public keys (`.pub` suffix) → 644
97+
- **Agent state** parsed from `ssh-add -L` output; refusing-no-agent
98+
exits 69 with a hint to run `shimkit ssh agent start`.
99+
100+
## Test minimums per tool (CONTRIBUTING.md baseline)
101+
102+
For each: `boot()` smoke, `boot()` 69 on wrong platform (if
103+
applicable), `boot()` 69 on missing extra (n/a — no extras), every
104+
non-interactive subcommand one happy + one sad path, `--json` parses,
105+
`--dry-run` makes zero `CommandRunner.run` calls (assert via
106+
monkeypatch), MODERATE prompts blocked under `--no-input`. Severe
107+
prompts abort without the right token.
108+
109+
Mock at `CommandRunner.run`, `Platform.detect`, and Path-level
110+
filesystem fixtures via `tmp_path`. Never touch real `~/.ssh` or
111+
`/etc/hosts`.
112+
113+
## Config additions to `defaults.json`
114+
115+
```json
116+
{
117+
"tools": {
118+
"ports": {
119+
"default_signal": "TERM",
120+
"init_pid_severe_token": "KILL-INIT"
121+
},
122+
"hosts": {
123+
"hosts_path": "/etc/hosts",
124+
"apply_list_severe_token": "APPLY-LIST",
125+
"max_entries_per_apply": 5000
126+
},
127+
"ssh": {
128+
"ssh_dir": "~/.ssh",
129+
"default_key_type": "ed25519",
130+
"perms": {
131+
"dir": "700",
132+
"private_key": "600",
133+
"public_key": "644",
134+
"config": "644",
135+
"known_hosts": "644",
136+
"authorized_keys": "644"
137+
}
138+
}
139+
}
140+
}
141+
```
142+
143+
## Out of scope this cycle
144+
145+
- **YubiKey / hardware-token SSH keys** — adds vendor SDKs; revisit
146+
if there's demand.
147+
- **GPG keys** for git signing — separate `shimkit gpg` tool, not
148+
bundled.
149+
- **Cloud DNS / hosts** (Route53 + Cloudflare etc.) — out of charter
150+
(not host-machine).
151+
- **NetworkManager / nmcli wrappers beyond what `adguard` already
152+
does** — would need its own tool.
153+
- **Windows support** — explicit charter exclusion.
154+
155+
## Acceptance gates for v0.3.0
156+
157+
- All five core architecture rules upheld in each new tool.
158+
- 233 → ~370 tests (the +130 baseline test floor across three tools).
159+
- Coverage stays above the 65% floor; aim to nudge toward 70%.
160+
- ruff + mypy strict still clean.
161+
- `shimkit --help` shows the three new sub-apps.
162+
- `docs/tools/{ports,hosts,ssh}.md` present, following the
163+
established template.
164+
- README's tool list extended.
165+
- CHANGELOG `[0.3.0]` entry written.

docs/tools/ports.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# shimkit ports
2+
3+
Cross-platform inspector + killer for the process holding a TCP or
4+
UDP port. Built for the day-to-day case of "port 5000 is in use" when
5+
you don't know what's holding it, or you do and you want it gone.
6+
7+
macOS reads `lsof -nP -iTCP -sTCP:LISTEN -iUDP -F pcnuP`. Linux
8+
reads `ss -tulnpH`. Both outputs are normalised to a single
9+
`PortOwner` dataclass via the pure parsers in
10+
`src/shimkit/tools/ports/owners.py` (unit-testable without
11+
shelling out).
12+
13+
## Commands
14+
15+
| Command | Purpose |
16+
|------------------------------------------|----------------------------------------------------------------------|
17+
| `shimkit ports` | Interactive menu (list-only — kill is subcommand-only). |
18+
| `shimkit ports show` | List every listening socket and the process holding it. |
19+
| `shimkit ports show <port>` | Same, narrowed to one port. |
20+
| `shimkit ports kill <port>` | Signal the holder(s). MODERATE prompt by default. |
21+
22+
Every command accepts the standard shared flags: `--quiet`,
23+
`--verbose`, `--log-file`, `--no-color`, `--color`, `--no-input`
24+
(place these before the subcommand). Per-command flags
25+
(`--json`, `--dry-run`, `--yes`, `--force`, `--confirm`,
26+
`--signal`) go after.
27+
28+
## Killing a port
29+
30+
```bash
31+
shimkit ports kill 5000 # interactive MODERATE prompt
32+
shimkit ports kill 5000 --yes # skip the prompt
33+
shimkit ports kill 5000 --signal KILL --yes
34+
shimkit ports kill 5000 --dry-run # show targets without signalling
35+
```
36+
37+
Allowed signals: `TERM` (default), `KILL`, `INT`, `HUP`. Anything
38+
else is refused with exit 1 — this CLI is for stopping stuck dev
39+
servers, not for arbitrary IPC.
40+
41+
## Severe tier — system processes
42+
43+
Killing a process with a low PID (below
44+
`tools.ports.system_pid_threshold`, default `100`) requires the
45+
severe-tier token:
46+
47+
```bash
48+
shimkit ports kill 53 --yes --confirm KILL-INIT
49+
```
50+
51+
The default threshold catches systemd-side services on Linux
52+
(systemd-resolved, systemd-networkd, etc.) and the early-boot helpers
53+
on macOS. Bumping the threshold lower in `~/.config/shimkit/shimkit.json`
54+
trades safety for convenience.
55+
56+
Killing pid 1 specifically is refused at any token — `init` is never
57+
the right answer.
58+
59+
## JSON output
60+
61+
```bash
62+
$ shimkit ports show --json
63+
{
64+
"ts": "...",
65+
"tool": "ports",
66+
"step": "show",
67+
"status": "ok",
68+
"data": {
69+
"port": null,
70+
"owners": [
71+
{"port": 80, "proto": "tcp", "pid": 1234,
72+
"name": "nginx", "user": "nobody", "address": null}
73+
]
74+
}
75+
}
76+
```
77+
78+
`port` is `null` when the call was unfiltered. `address` is
79+
`null` for wildcard binds (`0.0.0.0`, `*`, `::`).
80+
81+
## Configuration
82+
83+
```json
84+
{
85+
"tools": {
86+
"ports": {
87+
"default_signal": "TERM",
88+
"init_pid_severe_token": "KILL-INIT",
89+
"system_pid_threshold": 100
90+
}
91+
}
92+
}
93+
```
94+
95+
## Exit codes
96+
97+
| Code | Meaning |
98+
|-----:|------------------------------------------------------|
99+
| 0 | success / no-op (port empty / nothing to do) |
100+
| 1 | generic failure (disallowed signal, refused tier, prompt cancelled) |
101+
| 2 | Typer usage error |
102+
| 69 | EX_UNAVAILABLE — wrong platform or `lsof`/`ss` missing |
103+
| 130 | SIGINT |
104+
105+
## Platform support
106+
107+
| Platform | Status |
108+
|----------|--------|
109+
| macOS | ✓ via `lsof` (preinstalled on every supported macOS). |
110+
| Linux | ✓ via `ss` from `iproute2` (preinstalled on most distros). |
111+
| WSL | ✓ (Linux path). |
112+
| Windows | ✗ — out of charter. |

src/shimkit/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from shimkit.tools.dns.commands import dns_app
1919
from shimkit.tools.docker_clean.commands import docker_clean_app
2020
from shimkit.tools.java.commands import java_app
21+
from shimkit.tools.ports.commands import ports_app
2122
from shimkit.tools.shell.commands import shell_app
2223

2324
app = typer.Typer(
@@ -34,6 +35,7 @@
3435
app.add_typer(dns_app)
3536
app.add_typer(adguard_app)
3637
app.add_typer(docker_clean_app)
38+
app.add_typer(ports_app)
3739

3840

3941
# --- config -----------------------------------------------------------------

src/shimkit/config/defaults.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@
109109
"kubernetes_image_patterns": ["registry.k8s.io", "kube-", "kubernetes", "desktop-"],
110110
"daemon_verify_timeout_seconds": 30,
111111
"default_buildx_prune_all": true
112+
},
113+
"ports": {
114+
"default_signal": "TERM",
115+
"init_pid_severe_token": "KILL-INIT",
116+
"system_pid_threshold": 100
112117
}
113118
},
114119
"package_managers": {

src/shimkit/config/schema.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,24 @@ class DockerCleanConfig(_StrictModel):
152152
default_buildx_prune_all: bool = True
153153

154154

155+
class PortsConfig(_StrictModel):
156+
"""Port owner inspection + killer — `shimkit ports`."""
157+
158+
default_signal: str = "TERM"
159+
init_pid_severe_token: str = "KILL-INIT"
160+
# Killing a PID below this threshold is treated as a system-process
161+
# operation and prompts the severe-tier token. Linux services live
162+
# below ~1000; user-launched dev servers are typically >1000.
163+
system_pid_threshold: int = Field(default=100, ge=1, le=65535)
164+
165+
155166
class ToolsConfig(_StrictModel):
156167
java: JavaConfig
157168
shell: ShellToolConfig
158169
dns: DnsConfig = Field(default_factory=DnsConfig)
159170
adguard: AdGuardConfig = Field(default_factory=AdGuardConfig)
160171
docker_clean: DockerCleanConfig = Field(default_factory=DockerCleanConfig)
172+
ports: PortsConfig = Field(default_factory=PortsConfig)
161173

162174

163175
class PackageManagerEntry(_StrictModel):
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Port owner inspection + killer — ``shimkit ports``.
2+
3+
Cross-platform listing of which process holds each TCP/UDP port and a
4+
prompted kill helper for stuck dev servers. ``lsof`` drives the macOS
5+
path; ``ss`` drives the Linux path. No third-party deps.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from .manager import PortsManager
11+
from .models import PortOwner
12+
13+
__all__ = ["PortOwner", "PortsManager"]

0 commit comments

Comments
 (0)