{
  "format" : 1,
  "manifest" : {
    "accent" : "teal",
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "lsof",
            "kill"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Ports",
    "id" : "io.navbytes.stackbar.plugins.ports",
    "minimumStackbar" : "0.17.0",
    "name" : "Ports",
    "summary" : "Which process is listening on which port, and a way to stop it.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "symbol" : "point.3.connected.trianglepath.dotted",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 30
        }
      }
    ],
    "version" : "1.3.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "8OT3N2q0tfpxU6JklHiFrlnToJVjMrj1H2vkj7Xrp3N9iXQ3waMaXBdZudgjsUaZs4n/X6bLK4O2dNg4z3JdDQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// What is listening on which port, and a way to stop it.\n///\n/// \"Kill Port 3000\" from the palette ends whatever holds the port that a dev\n/// server forgot to let go of. The panel lists every listening TCP port with\n/// its process, from `lsof`, run through Stackbar every 30 seconds. Stopping sends SIGTERM, and SIGKILL only when you ask.\n@main\nfinal class Ports: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.ports\",\n        name: \"Ports\",\n        version: \"1.3.0\",\n        summary: \"Which process is listening on which port, and a way to stop it.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch, .every(.seconds(30))],\n        capabilities: [.run(commands: [\"lsof\", \"kill\"])],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Ports\",\n        accent: .teal,\n        symbol: \"point.3.connected.trianglepath.dotted\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Ports>] = [\n        PreviewScene(\"hero\", \"What's listening, and one click to stop it\") { plugin in\n            plugin.listeners = [\n                Listener(port: 3000, address: \"127.0.0.1\", pid: 48211, process: \"node\", user: \"you\"),\n                Listener(port: 5173, address: \"[::1]\", pid: 48390, process: \"vite\", user: \"you\"),\n                Listener(port: 5432, address: \"127.0.0.1\", pid: 712, process: \"postgres\", user: \"you\"),\n                Listener(port: 6379, address: \"127.0.0.1\", pid: 733, process: \"redis-server\", user: \"you\"),\n                Listener(port: 8080, address: \"*\", pid: 51022, process: \"java\", user: \"you\"),\n                Listener(port: 9229, address: \"127.0.0.1\", pid: 48211, process: \"node\", user: \"you\"),\n                Listener(port: 11434, address: \"127.0.0.1\", pid: 1880, process: \"ollama\", user: \"you\"),\n            ]\n            plugin.loaded = true\n        },\n    ]\n\n    struct Listener: Equatable, Identifiable {\n        var port: Int\n        var address: String\n        var pid: Int\n        var process: String\n        var user: String\n        var id: String { \"\\(pid):\\(address):\\(port)\" }\n        var isLocalOnly: Bool { address.hasPrefix(\"127.\") || address == \"[::1]\" || address == \"localhost\" }\n    }\n\n    var listeners: [Listener] = []\n    var loaded = false\n    var problem: String?\n    var notice: String?\n    var noticeOK = true\n\n    @Stored(\"hideSystem\") var hideSystem = true\n    @Stored(\"minimumPort\") var minimumPort = 1024\n\n    var shown: [Listener] {\n        listeners.filter { $0.port >= (hideSystem ? minimumPort : 0) }\n    }\n\n    static let ranges = [\"All\", \"1024+\", \"3000+\"]\n    var range: String { !hideSystem || minimumPort <= 1 ? \"All\" : minimumPort >= 3000 ? \"3000+\" : \"1024+\" }\n\n    func setRange(_ choice: String) {\n        switch choice {\n        case \"All\": hideSystem = false\n        case \"3000+\": hideSystem = true; minimumPort = 3000\n        default: hideSystem = true; minimumPort = 1024\n        }\n    }\n\n    func refresh() async throws {\n        notice = nil\n        await load()\n    }\n\n    func load() async {\n        do {\n            // -F prints one field a line: p (pid), c (command), L (login), n (name).\n            // +c 0 prints the whole command name; lsof cuts it to 9 characters otherwise.\n            let result = try await Stackbar.run(\"lsof\", [\"+c\", \"0\", \"-nP\", \"-iTCP\", \"-sTCP:LISTEN\", \"-FpcLn\"], timeout: .seconds(20))\n            // lsof exits 1 when nothing matches.\n            guard result.succeeded || (result.status == 1 && result.errorText.isEmpty) else {\n                problem = \"lsof failed (\\(result.status)): \\(result.errorText.prefix(200))\"\n                loaded = true\n                return\n            }\n            listeners = Self.parse(result.text)\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        loaded = true\n    }\n\n    /// Parses `lsof -F pcLn` output; one entry per process and port.\n    static func parse(_ output: String) -> [Listener] {\n        var found: [Listener] = []\n        var pid = 0\n        var command = \"\"\n        var user = \"\"\n        for line in output.split(separator: \"\\n\") {\n            guard let field = line.first else { continue }\n            let value = String(line.dropFirst())\n            switch field {\n            case \"p\":\n                pid = Int(value) ?? 0\n                command = \"\"\n                user = \"\"\n            case \"c\": command = value\n            case \"L\": user = value\n            case \"n\":\n                // *:3000, 127.0.0.1:5432, [::1]:8080\n                guard let colon = value.lastIndex(of: \":\"), let port = Int(value[value.index(after: colon)...]) else { continue }\n                let address = String(value[..<colon])\n                let listener = Listener(port: port, address: address == \"*\" ? \"all interfaces\" : address, pid: pid, process: command, user: user)\n                if !found.contains(where: { $0.pid == pid && $0.port == port }) { found.append(listener) }\n            default: break\n            }\n        }\n        return found.sorted { ($0.port, $0.pid) < ($1.port, $1.pid) }\n    }\n\n    func stop(_ listener: Listener, force: Bool) async throws -> String {\n        let result = try await Stackbar.run(\"kill\", [force ? \"-KILL\" : \"-TERM\", \"\\(listener.pid)\"], timeout: .seconds(10))\n        guard result.succeeded else {\n            let reason = result.errorText.contains(\"not permitted\") ? \"it belongs to another user\" : result.errorText.trimmingCharacters(in: .whitespacesAndNewlines)\n            throw HostError(\"Couldn't stop \\(listener.process) (\\(listener.pid)): \\(reason)\")\n        }\n        try? await Task.sleep(for: .milliseconds(400))\n        await load()\n        let stillThere = listeners.contains { $0.pid == listener.pid }\n        let message = stillThere ? \"Asked \\(listener.process) to stop; it is still listening\" : \"Stopped \\(listener.process) on :\\(listener.port)\"\n        notice = message\n        noticeOK = !stillThere\n        return message\n    }\n\n    // MARK: Surfaces\n\n    let theme = Theme.teal\n\n    var panel: ViewContent {\n        VStack(spacing: Space.m) {\n            if let problem {\n                EmptyState(\"Can't list ports\", symbol: \"exclamationmark.triangle.fill\", message: problem, theme: .critical) {\n                    ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme) { await self.load() }\n                }\n            } else if !loaded {\n                PanelHeader(\"Ports\", subtitle: \"Checking…\", symbol: \"network\", theme: theme)\n            } else {\n                let open = shown.filter { !$0.isLocalOnly }.count\n                PanelHeader(shown.isEmpty ? \"Nothing listening\" : \"\\(shown.count) listening\", subtitle: open > 0 ? \"\\(open) reachable\" : \"All local\", symbol: \"network\", theme: theme) {\n                    Picker(\"Show ports\", options: Self.ranges, selection: range, style: .segmented) { self.setRange($0) }\n                        .frame(width: 180)\n                }\n                if !shown.isEmpty {\n                    HeroCard(theme: theme, look: .glow) {\n                        HStack(spacing: Space.l) {\n                            HeroStat(\"Listening\", value: \"\\(shown.count)\", theme: theme, live: true)\n                            HeroStat(\"Local\", value: \"\\(shown.count - open)\", theme: .neutral, symbol: \"lock.fill\")\n                            HeroStat(\"Open\", value: \"\\(open)\", theme: open > 0 ? .warning : .neutral, symbol: \"globe\")\n                            Spacer()\n                        }\n                    }\n                }\n                if let notice { Banner(notice, symbol: noticeOK ? \"checkmark.circle.fill\" : \"exclamationmark.triangle.fill\", theme: noticeOK ? .success : .warning) }\n                if shown.isEmpty {\n                    EmptyState(\"Nothing is listening\", symbol: \"network\", message: range == \"All\" ? nil : \"Showing ports from \\(minimumPort) up.\", theme: theme) {\n                        if range != \"All\" { ActionButton(\"Show All Ports\", theme: theme) { self.setRange(\"All\") } }\n                    }\n                } else {\n                    List(shown, searchable: true) { listener in self.row(listener) }\n                }\n            }\n        }\n    }\n\n    func row(_ listener: Listener) -> Row {\n        let address = listener.isLocalOnly || listener.address == \"all interfaces\" ? \"\" : \" · \\(listener.address)\"\n        var row = Row(\":\\(listener.port)\", subtitle: \"\\(listener.process) · pid \\(listener.pid)\\(address)\", symbol: listener.isLocalOnly ? \"lock.fill\" : \"network\", key: listener.id)\n        if !listener.isLocalOnly { row = row.badge(\"LAN\", role: .warning) }\n        return row\n            .action(\"Open in Browser\") { Stackbar.open(\"http://localhost:\\(listener.port)\") }\n            .action(\"Stop Process\", shortcut: \"cmd+.\", role: .critical) { _ = try await self.stop(listener, force: false) }\n            .action(\"Force Quit Process\", shortcut: \"cmd+shift+.\", role: .critical) { _ = try await self.stop(listener, force: true) }\n            .action(\"Copy PID\", shortcut: \"cmd+shift+c\") { Stackbar.copy(\"\\(listener.pid)\") }\n    }\n\n    var settings: ViewContent {\n        Section(\"Show\") {\n            Picker(\"Show ports\", options: Self.ranges, selection: range, style: .segmented) { self.setRange($0) }\n        }\n        Section(\"About\") {\n            Text(\"Ports lists TCP ports that processes you can see are listening on. Stopping one sends it SIGTERM, so it can clean up; Force Quit sends SIGKILL. A process owned by another user (like a system service) can't be stopped from here.\").style(.caption)\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"kill-port\", title: \"Kill Port\", subtitle: \"Stop whatever is listening on a port\", symbol: \"xmark.octagon\", keywords: [\"port\", \"kill\", \"lsof\", \"free\"]) { invocation in\n            guard let port = Int(invocation[\"port\"] ?? \"\") else { return .failure(\"Give a port number, like 3000\") }\n            await self.load()\n            let holders = self.listeners.filter { $0.port == port }\n            guard !holders.isEmpty else { return .message(\"Nothing is listening on \\(port)\") }\n            let force = invocation[\"signal\"] == \"KILL\"\n            var messages: [String] = []\n            for holder in Dictionary(grouping: holders, by: \\.pid).values.compactMap(\\.first) {\n                messages.append(try await self.stop(holder, force: force))\n            }\n            return .message(messages.joined(separator: \"; \"))\n        }\n        .argument(\"port\", placeholder: \"3000\")\n        .argument(\"signal\", required: false, default: \"TERM\", options: [\"TERM\", \"KILL\"])\n        Command(\"ports\", title: \"Listening Ports\", symbol: \"network\", keywords: [\"port\", \"lsof\", \"listen\", \"server\"])\n            .view(search: { _ in await self.load() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't list ports\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    List(self.shown, searchable: true) { listener in self.row(listener) }\n                }\n            }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"listening\", description: \"TCP ports being listened on, as port, process, pid and address\") { _ in\n            await self.load()\n            if let problem = self.problem { return problem }\n            return self.listeners.map { \"\\($0.port)\\t\\($0.process)\\t\\($0.pid)\\t\\($0.address)\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "99ec0f3dee7eb3f63398f700cb1e3622eb650454c0e7315e0a292d8514d55ebb"
}