{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "brew"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Homebrew",
    "id" : "io.navbytes.stackbar.plugins.homebrew",
    "minimumStackbar" : "0.11.0",
    "name" : "Homebrew",
    "summary" : "Outdated formulae and casks, with upgrades a click away.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 3600
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "MT6HCVhaGnurd4CZbUPzbOwrBH9ftJQFzcdZIFTmkkTi2uGUaDpeJmEKPWjPCTAzx6WjGiOZKr0K7tYq6c0/Dw==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// What Homebrew can upgrade, in the menu bar, with upgrades one click away.\n///\n/// Every hour (and on wake) it runs `brew outdated --json=v2`, which reads\n/// what Homebrew already knows and is quick. \"Update Homebrew\" runs\n/// `brew update` to fetch the latest formulae first. Runs go through\n/// Stackbar, which finds `brew` in /opt/homebrew/bin or /usr/local/bin and\n/// asks you to approve that path once.\n@main\nfinal class Homebrew: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.homebrew\",\n        name: \"Homebrew\",\n        version: \"1.0.0\",\n        summary: \"Outdated formulae and casks, with upgrades a click away.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .minutes(60), .wake],\n        capabilities: [.run(commands: [\"brew\"])],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Homebrew\"\n    )\n\n    struct Package: Equatable, Identifiable {\n        var name: String\n        var installed: String\n        var current: String\n        var isCask: Bool\n        var pinned: Bool\n        var id: String { (isCask ? \"cask:\" : \"formula:\") + name }\n    }\n\n    /// `brew outdated --json=v2`.\n    struct Outdated: Decodable {\n        struct Item: Decodable {\n            let name: String\n            let installed_versions: Versions\n            let current_version: String\n            let pinned: Bool?\n        }\n\n        /// A list for formulae; casks have sent both a list and a string.\n        struct Versions: Decodable {\n            let values: [String]\n            init(from decoder: Decoder) throws {\n                let container = try decoder.singleValueContainer()\n                if let list = try? container.decode([String].self) {\n                    values = list\n                } else {\n                    values = [try container.decode(String.self)]\n                }\n            }\n        }\n\n        let formulae: [Item]\n        let casks: [Item]\n    }\n\n    var packages: [Package] = []\n    var checked: Date?\n    var problem: String?\n    /// What is running now, like \"Upgrading node\".\n    var busy: String?\n    var lastResult: String?\n\n    @Stored(\"includeCasks\") var includeCasks = true\n    @Stored(\"hidePinned\") var hidePinned = true\n    @Stored(\"greedy\") var greedy = false\n    @Stored(\"notify\") var notifyNew = false\n\n    var shown: [Package] {\n        packages.filter { (includeCasks || !$0.isCask) && (!hidePinned || !$0.pinned) }\n    }\n\n    func refresh() async throws {\n        await check()\n    }\n\n    func check() async {\n        var arguments = [\"outdated\", \"--json=v2\"]\n        if greedy { arguments.append(\"--greedy\") }\n        do {\n            let result = try await Stackbar.run(\"brew\", arguments, environment: Self.quiet, timeout: .seconds(120))\n            guard result.succeeded else {\n                problem = Self.failure(result, doing: \"brew outdated\")\n                return\n            }\n            let reply = try JSONDecoder().decode(Outdated.self, from: result.stdout)\n            let fresh = reply.formulae.map { Package(name: $0.name, installed: $0.installed_versions.values.last ?? \"?\", current: $0.current_version, isCask: false, pinned: $0.pinned ?? false) }\n                + reply.casks.map { Package(name: $0.name, installed: $0.installed_versions.values.last ?? \"?\", current: $0.current_version, isCask: true, pinned: false) }\n            if notifyNew, checked != nil {\n                let known = Set(packages.map(\\.id))\n                let new = fresh.filter { !known.contains($0.id) && (includeCasks || !$0.isCask) }\n                if !new.isEmpty {\n                    Stackbar.notify(\"Homebrew: \\(new.count) new update\\(new.count == 1 ? \"\" : \"s\")\", body: new.prefix(5).map(\\.name).joined(separator: \", \"))\n                }\n            }\n            packages = fresh.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }\n            checked = Date()\n            problem = nil\n        } catch {\n            problem = Self.explain(error)\n        }\n    }\n\n    /// No auto-update, hints or analytics noise in the output.\n    static let quiet = [\"HOMEBREW_NO_AUTO_UPDATE\": \"1\", \"HOMEBREW_NO_ENV_HINTS\": \"1\", \"HOMEBREW_NO_INSTALL_CLEANUP\": \"1\", \"HOMEBREW_NO_COLOR\": \"1\"]\n\n    static func explain(_ error: Error) -> String {\n        let text = \"\\(error)\"\n        if text.contains(\"not installed\") || text.contains(\"not found\") {\n            return \"Homebrew isn't installed. Get it from brew.sh, then refresh.\"\n        }\n        return text\n    }\n\n    static func failure(_ result: RunResult, doing what: String) -> String {\n        if result.timedOut { return \"\\(what) took too long\" }\n        let message = result.errorText.split(separator: \"\\n\").last { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.map(String.init)\n        return \"\\(what) failed (\\(result.status))\" + (message.map { \": \\($0)\" } ?? \"\")\n    }\n\n    // MARK: Actions\n\n    func upgrade(_ names: [String], label: String) async throws {\n        guard busy == nil else { throw HostError(\"\\(busy!) is still running\") }\n        busy = label\n        defer { busy = nil }\n        let result = try await Stackbar.run(\"brew\", [\"upgrade\"] + names, environment: Self.quiet, timeout: .seconds(600))\n        lastResult = result.succeeded ? \"\\(label): done\" : Self.failure(result, doing: label)\n        await check()\n        if !result.succeeded { throw HostError(lastResult!) }\n    }\n\n    func update() async throws -> String {\n        guard busy == nil else { throw HostError(\"\\(busy!) is still running\") }\n        busy = \"Updating Homebrew\"\n        defer { busy = nil }\n        let result = try await Stackbar.run(\"brew\", [\"update\"], environment: [\"HOMEBREW_NO_ENV_HINTS\": \"1\", \"HOMEBREW_NO_COLOR\": \"1\"], timeout: .seconds(300))\n        guard result.succeeded else { throw HostError(Self.failure(result, doing: \"brew update\")) }\n        await check()\n        lastResult = \"Updated; \\(shown.count) outdated\"\n        return lastResult!\n    }\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        if busy != nil {\n            Label(\"…\", symbol: \"arrow.triangle.2.circlepath\")\n        } else if problem != nil {\n            Label(\"!\", symbol: \"mug\").role(.warning)\n        } else {\n            Label(shown.isEmpty ? \"\" : \"\\(shown.count)\", symbol: \"mug\").role(shown.isEmpty ? .secondary : .accent)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't check Homebrew\", symbol: \"exclamationmark.triangle\", message: problem)\n            Button(\"Try Again\", symbol: \"arrow.clockwise\") { await self.check() }\n        } else if checked == nil {\n            ContentUnavailableView(\"Checking…\", symbol: \"mug\")\n        } else if shown.isEmpty {\n            ContentUnavailableView(\"Everything is up to date\", symbol: \"checkmark.seal\", message: checkedText)\n            Button(\"Update Homebrew\", symbol: \"arrow.down.circle\") { _ = try await self.update() }\n        } else {\n            if let busy { Label(busy, symbol: \"arrow.triangle.2.circlepath\").role(.accent) }\n            List(shown, searchable: shown.count > 8) { package in\n                self.row(package)\n            }\n            HStack {\n                Button(\"Upgrade All\", symbol: \"arrow.up.circle\", role: .accent) {\n                    try await self.upgrade(self.shown.map(\\.name), label: \"Upgrading everything\")\n                }\n                Button(\"Update Homebrew\", symbol: \"arrow.down.circle\") { _ = try await self.update() }\n            }\n            Text(checkedText + (lastResult.map { \" · \\($0)\" } ?? \"\")).style(.caption)\n        }\n    }\n\n    var checkedText: String {\n        guard let checked else { return \"\" }\n        return \"Checked \" + checked.formatted(date: .omitted, time: .shortened)\n    }\n\n    func row(_ package: Package) -> Row {\n        var row = Row(package.name, subtitle: \"\\(package.installed) → \\(package.current)\", symbol: package.isCask ? \"macwindow\" : \"shippingbox\", key: package.id)\n        if package.isCask { row = row.badge(\"cask\") }\n        if package.pinned { row = row.badge(\"pinned\", role: .warning) }\n        return row\n            .action(\"Upgrade\") { try await self.upgrade([package.name], label: \"Upgrading \\(package.name)\") }\n            .action(\"Open Homepage\", shortcut: \"cmd+o\") {\n                Stackbar.open(\"https://formulae.brew.sh/\\(package.isCask ? \"cask\" : \"formula\")/\\(package.name)\")\n            }\n            .action(\"Copy Upgrade Command\", shortcut: \"cmd+shift+c\") {\n                Stackbar.copy(\"brew upgrade \\(package.isCask ? \"--cask \" : \"\")\\(package.name)\")\n            }\n    }\n\n    var settings: ViewContent {\n        Section(\"Show\") {\n            Toggle(\"Casks\", isOn: includeCasks) { self.includeCasks = $0 }\n            Toggle(\"Hide pinned formulae\", isOn: hidePinned) { self.hidePinned = $0 }\n            Toggle(\"Casks that update themselves (--greedy)\", isOn: greedy) { value in\n                self.greedy = value\n                await self.check()\n            }\n        }\n        Section(\"Notify\") {\n            Toggle(\"When new updates appear\", isOn: notifyNew) { self.notifyNew = $0 }\n        }\n        Section(\"How it checks\") {\n            Text(\"Hourly and on wake, Stackbar runs brew outdated, which reads what Homebrew already knows. Update Homebrew runs brew update to fetch the latest. Nothing runs in a shell.\").style(.caption)\n        }\n    }\n\n    var commands: [Command] {\n        Command(\"upgrade-all\", title: \"Upgrade All Homebrew Packages\", symbol: \"arrow.up.circle\", keywords: [\"brew\", \"homebrew\", \"upgrade\"]) { _ in\n            await self.check()\n            let names = self.shown.map(\\.name)\n            guard !names.isEmpty else { return .message(\"Everything is up to date\") }\n            try await self.upgrade(names, label: \"Upgrading \\(names.count) package\\(names.count == 1 ? \"\" : \"s\")\")\n            return .message(\"Upgraded \\(names.count)\")\n        }\n        Command(\"update\", title: \"Update Homebrew\", symbol: \"arrow.down.circle\", keywords: [\"brew\", \"homebrew\"]) { _ in\n            .message(try await self.update())\n        }\n        Command(\"outdated\", title: \"Outdated Homebrew Packages\", symbol: \"mug\", keywords: [\"brew\", \"homebrew\"])\n            .view(search: { _ in await self.check() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't check Homebrew\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else if self.shown.isEmpty {\n                    ContentUnavailableView(\"Everything is up to date\", symbol: \"checkmark.seal\")\n                } else {\n                    List(self.shown, searchable: true) { package in self.row(package) }\n                }\n            }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"outdated\", description: \"Homebrew formulae and casks with updates, as name installed -> current\") { _ in\n            self.shown.isEmpty ? \"Everything is up to date\" : self.shown.map { \"\\($0.name) \\($0.installed) -> \\($0.current)\\($0.isCask ? \" (cask)\" : \"\")\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "c8faf0e44f985cef5b0f75f60c56645a40ca98bd50196bf3374a6311bc824d88"
}