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

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

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "LpBfFEFCPW8WxhFbvha7/mYWJdC7WTTDNZ1yQIklysTZ2A3iuaB3jnJQei+RlV7PeUWWTMOoRX4DTwJDlG/UDg==",
  "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. Every few hours it first runs\n/// `brew update` in the background so that list is not stale, and \"Update\n/// Homebrew\" does the same on demand. 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.4.0\",\n        summary: \"Outdated formulae and casks, with upgrades a click away.\",\n        surfaces: [.menuBar, .panel, .widget, .settings],\n        triggers: [.launch, .minutes(60), .wake],\n        capabilities: [.run(commands: [\"brew\"])],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Homebrew\",\n        accent: .amber,\n        symbol: \"mug.fill\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<Homebrew>] = [\n        PreviewScene(\"hero\", \"Every outdated formula and cask, one click to upgrade\") { plugin in\n            plugin.packages = samples\n            plugin.checked = Date().addingTimeInterval(-6 * 60)\n            plugin.indexUpdated = Date().addingTimeInterval(-6 * 60)\n        },\n        PreviewScene(\"widget\", \"Updates waiting, on your desktop\", surface: .widget) { plugin in\n            plugin.packages = samples\n            plugin.checked = Date().addingTimeInterval(-6 * 60)\n            plugin.indexUpdated = Date().addingTimeInterval(-6 * 60)\n        },\n    ]\n\n    static let samples = [\n        Package(name: \"node\", installed: \"22.11.0\", current: \"22.12.0\", isCask: false, pinned: false),\n        Package(name: \"git\", installed: \"2.47.0\", current: \"2.47.1\", isCask: false, pinned: false),\n        Package(name: \"ripgrep\", installed: \"14.1.0\", current: \"14.1.1\", isCask: false, pinned: false),\n        Package(name: \"python@3.13\", installed: \"3.13.0\", current: \"3.13.1\", isCask: false, pinned: true),\n        Package(name: \"visual-studio-code\", installed: \"1.95.1\", current: \"1.95.3\", isCask: true, pinned: false),\n        Package(name: \"firefox\", installed: \"132.0\", current: \"132.0.2\", isCask: true, pinned: false),\n        Package(name: \"orbstack\", installed: \"1.8.1\", current: \"1.8.2\", isCask: true, pinned: false),\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    var lastFailed = false\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    /// Run `brew update` in the background when the formulae list is older than this.\n    @Stored(\"autoUpdate\") var autoUpdate = true\n    /// When `brew update` last succeeded, from here or the button.\n    @Stored(\"indexUpdated\") var indexUpdated: Date? = nil\n\n    static let staleAfter: TimeInterval = 6 * 3600\n\n    /// Whether the background `brew update` is due.\n    func indexIsStale(now: Date = Date()) -> Bool {\n        guard let indexUpdated else { return true }\n        return now.timeIntervalSince(indexUpdated) >= Self.staleAfter\n    }\n\n    var shown: [Package] {\n        packages.filter { (includeCasks || !$0.isCask) && (!hidePinned || !$0.pinned) }\n    }\n\n    func refresh() async throws {\n        if busy == nil { lastResult = nil }\n        // Not on the first check after launch, which should show the list\n        // right away; the next hourly or wake check fetches first.\n        if autoUpdate, busy == nil, checked != nil, indexIsStale() {\n            // Quietly: a failed fetch (offline, say) still leaves the last list to show.\n            _ = try? await fetchIndex(label: \"Fetching the latest formulae\")\n        }\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        lastFailed = !result.succeeded\n        await check()\n        if !result.succeeded { throw HostError(lastResult!) }\n    }\n\n    /// Runs `brew update` and records when it worked.\n    func fetchIndex(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\", [\"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        indexUpdated = Date()\n    }\n\n    func update() async throws -> String {\n        try await fetchIndex(label: \"Updating Homebrew\")\n        await check()\n        lastResult = \"Homebrew updated; \\(shown.count) outdated\"\n        lastFailed = false\n        return lastResult!\n    }\n\n    // MARK: Surfaces\n\n    let theme = Theme.amber\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 if shown.isEmpty {\n            Label(\"\", symbol: \"mug\").role(.secondary)\n        } else {\n            Label(\"\\(shown.count)\", symbol: \"mug.fill\")\n        }\n    }\n\n    var upgradable: [Package] { shown.filter { !$0.pinned } }\n\n    var panel: ViewContent {\n        VStack(spacing: Space.m) {\n            if checked == nil && problem == nil {\n                PanelHeader(\"Homebrew\", subtitle: \"Checking…\", symbol: \"mug.fill\", theme: theme)\n            } else if let problem, checked == nil {\n                let missing = problem.contains(\"isn't installed\")\n                EmptyState(missing ? \"Homebrew isn't installed\" : \"Can't check Homebrew\", symbol: missing ? \"mug\" : \"exclamationmark.triangle.fill\", message: problem, theme: missing ? theme : .critical) {\n                    ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme) { await self.check() }\n                }\n                if missing { Link(\"Get Homebrew at brew.sh\", url: \"https://brew.sh\") }\n            } else {\n                PanelHeader(shown.isEmpty ? \"Up to date\" : \"\\(shown.count) update\\(shown.count == 1 ? \"\" : \"s\")\", subtitle: checkedText, symbol: \"mug.fill\", theme: theme) {\n                    IconButton(\"arrow.down.circle\", label: \"Update Homebrew\", theme: theme, prominence: .tinted) { _ = try await self.update() }\n                    if upgradable.count >= 2 {\n                        ActionButton(\"Upgrade All\", theme: theme, prominent: true) {\n                            try await self.upgrade(self.upgradable.map(\\.name), label: \"Upgrading everything\")\n                        }\n                    }\n                }\n                HeroCard(theme: shown.isEmpty ? .success : theme, look: .glow) {\n                    HStack(spacing: Space.l) {\n                        if shown.isEmpty {\n                            Image(systemName: \"checkmark.seal.fill\").font(size: 30, weight: .semibold).foreground(Theme.success.textPaint).symbolEffect(.bounce, repeats: false)\n                            VStack(spacing: Space.xxs) {\n                                Text(\"Everything's fresh\").typography(.title)\n                                Text(checkedText).role(.secondary).typography(.caption)\n                            }\n                        } else {\n                            HeroStat(\"Outdated\", value: \"\\(shown.count)\", theme: theme, symbol: \"arrow.up.circle.fill\")\n                            HeroStat(\"Formulae\", value: \"\\(shown.filter { !$0.isCask }.count)\", theme: theme, symbol: \"terminal.fill\")\n                            HeroStat(\"Casks\", value: \"\\(shown.filter(\\.isCask).count)\", theme: theme, symbol: \"macwindow\")\n                        }\n                        Spacer()\n                    }\n                }\n                if let problem { Banner(problem) }\n                if let busy {\n                    Banner(\"\\(busy)… this can take a few minutes.\", symbol: \"arrow.triangle.2.circlepath\", theme: .info)\n                } else if let lastResult {\n                    Banner(lastResult, symbol: lastFailed ? \"xmark.octagon.fill\" : \"checkmark.circle.fill\", theme: lastFailed ? .critical : .success)\n                }\n                if shown.isEmpty {\n                    // The hero above already says it's all fresh.\n                    ActionButton(\"Update Homebrew\", symbol: \"arrow.down.circle\", theme: theme, fillWidth: true) { _ = try await self.update() }\n                } else {\n                    List(shown, searchable: shown.count > 8) { package in\n                        self.row(package)\n                    }\n                }\n            }\n        }\n    }\n\n    var widget: ViewContent {\n        HStack(alignment: .start, spacing: Space.l) {\n            Metric(\"\\(shown.count)\", unit: shown.count == 1 ? \"update\" : \"updates\", caption: checkedText, type: .metric)\n            VStack(spacing: Space.xxs) {\n                ForEach(shown.prefix(4)) { package in\n                    Text(package.name).typography(.caption)\n                }\n            }\n            Spacer()\n        }\n    }\n\n    var checkedText: String {\n        guard let checked else { return \"\" }\n        let text = \"Checked \" + checked.formatted(date: .omitted, time: .shortened)\n        guard let indexUpdated else { return text + \" · list never fetched\" }\n        return text + \" · list from \" + Self.ago(indexUpdated, now: checked)\n    }\n\n    /// \"just now\", \"3h ago\", \"2d ago\".\n    static func ago(_ date: Date, now: Date) -> String {\n        let minutes = max(0, Int(now.timeIntervalSince(date) / 60))\n        if minutes < 1 { return \"just now\" }\n        if minutes < 60 { return \"\\(minutes)m ago\" }\n        if minutes < 48 * 60 { return \"\\(minutes / 60)h ago\" }\n        return \"\\(minutes / 1440)d ago\"\n    }\n\n    func row(_ package: Package) -> Row {\n        var row = Row(package.name, subtitle: \"\\(package.installed) → \\(package.current)\", symbol: package.isCask ? \"macwindow\" : \"terminal\", key: package.id)\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(\"Include casks that update themselves\", isOn: greedy) { value in\n                self.greedy = value\n                await self.check()\n            }\n        }\n        Section(\"Stay current\") {\n            Toggle(\"Fetch the latest formulae every 6 hours\", isOn: autoUpdate) { self.autoUpdate = $0 }\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. Every 6 hours it runs brew update first, so that list stays current; Update Homebrew does it now. 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.filter { !$0.pinned }.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" : "4193ccf67bbc517f773858d5feacb1cd540a3c90954776e3d686643b57f330d2"
}