import AppKit
import Commander
import Foundation
import PeekabooCore
import PeekabooFoundation
/// Menu-specific errors
enum MenuError: Error {
case menuBarNotFound
case menuItemNotFound(String)
case submenuNotFound(String)
case menuExtraNotFound
case menuItemDisabled(String)
case menuOperationFailed(String)
}
extension MenuError: LocalizedError {
var errorDescription: String? {
switch self {
case .menuBarNotFound:
"Menu bar not found"
case let .menuItemNotFound(path):
"Menu item not found: \(path)"
case let .submenuNotFound(path):
"Submenu not found: \(path)"
case .menuExtraNotFound:
"Menu extra not found"
case let .menuItemDisabled(path):
"Menu item is disabled: \(path)"
case let .menuOperationFailed(reason):
"Menu operation failed: \(reason)"
}
}
}
/// Interact with application menu bar items and system menu extras
@MainActor
struct MenuCommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "menu",
abstract: "Interact with application menu bar",
discussion: """
Provides access to application menu bar items and system menu extras.
EXAMPLES:
# Click a simple menu item
peekaboo menu click --app Safari --item "New Window"
# Navigate nested menus with path
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
# Click system menu extras (WiFi, Bluetooth, etc.)
peekaboo menu click-extra --title "WiFi"
# List all menu items for an app
peekaboo menu list --app Finder
""",
subcommands: [
ClickSubcommand.self,
ClickExtraSubcommand.self,
ListSubcommand.self,
ListAllSubcommand.self,
],
showHelpOnEmptyInvocation: true
)
}
}
}
extension MenuCommand {
// MARK: - Click Menu Item
@MainActor
struct ClickSubcommand: OutputFormattable, ApplicationResolvablePositional {
@Option(help: "Target application by name, bundle ID, or 'PID:12345'")
var app: String
var positionalAppIdentifier: String { self.app }
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Option(help: "Menu item to click (for simple, non-nested items)")
var item: String?
@Option(help: "Menu path for nested items (e.g., 'File > Export > PDF')")
var path: String?
@OptionGroup var focusOptions: FocusCommandOptions
@RuntimeStorage private var runtime: CommandRuntime?
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.resolvedRuntime.configuration.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
self.logger.setJsonOutputMode(self.jsonOutput)
var normalizedItem = self.item
var normalizedPath = self.path
// Convert legacy --item inputs (containing '>') into proper paths so we accept
// the shape agents typically copy out of menu list output without requiring
// them to learn about --path vs --item flag differences.
let normalization = normalizeMenuSelection(item: normalizedItem, path: normalizedPath)
normalizedItem = normalization.item
normalizedPath = normalization.path
if normalization.convertedFromItem, let resolvedPath = normalizedPath {
let note = "Interpreting --item value as menu path: \(resolvedPath)"
if self.jsonOutput {
self.logger.info(note)
} else {
print("ℹ️ \(note)")
}
}
guard normalizedItem != nil || normalizedPath != nil else {
throw ValidationError("Must specify either --item or --path")
}
guard normalizedItem == nil || normalizedPath == nil else {
throw ValidationError("Cannot specify both --item and --path")
}
do {
let appIdentifier = try self.resolveApplicationIdentifier()
try await ensureFocusIgnoringMissingWindows(
applicationName: appIdentifier,
options: self.focusOptions,
services: self.services,
logger: self.logger
)
let canonicalPath: String? = normalizedPath.map(Self.canonicalizeMenuPath)
if let canonicalPath {
try await self.ensureMenuItemEnabled(appIdentifier: appIdentifier, menuPath: canonicalPath)
}
if let itemName = normalizedItem {
try await MenuServiceBridge.clickMenuItemByName(
menu: self.services.menu,
appIdentifier: appIdentifier,
itemName: itemName
)
} else if let path = canonicalPath {
try await MenuServiceBridge.clickMenuItem(
menu: self.services.menu,
appIdentifier: appIdentifier,
itemPath: path
)
}
let appInfo = try await self.services.applications.findApplication(identifier: appIdentifier)
let clickedPath = canonicalPath ?? normalizedItem!
if self.jsonOutput {
let data = MenuClickResult(
action: "menu_click",
app: appInfo.name,
menu_path: clickedPath,
clicked_item: clickedPath
)
outputSuccessCodable(data: data, logger: self.outputLogger)
} else {
print("✓ Clicked menu item: \(clickedPath)")
}
} catch let error as MenuError {
self.handleMenuError(error)
throw ExitCode(1)
} catch let error as PeekabooError {
self.handleApplicationError(error)
throw ExitCode(1)
} catch {
self.handleGenericError(error)
throw ExitCode(1)
}
}
private func handleMenuError(_ error: MenuError) {
if self.jsonOutput {
let errorCode: ErrorCode = switch error {
case .menuBarNotFound:
.MENU_BAR_NOT_FOUND
case .menuItemNotFound:
.MENU_ITEM_NOT_FOUND
case .submenuNotFound:
.MENU_ITEM_NOT_FOUND
case .menuExtraNotFound:
.MENU_ITEM_NOT_FOUND
case .menuItemDisabled:
.INTERACTION_FAILED
case .menuOperationFailed:
.INTERACTION_FAILED
}
outputError(
message: error.localizedDescription,
code: errorCode,
details: "Failed to click menu item",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleApplicationError(_ error: PeekabooError) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .APP_NOT_FOUND,
details: "Application not found",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleGenericError(_ error: any Error) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .UNKNOWN_ERROR,
details: "Menu operation failed",
logger: self.outputLogger
)
} else {
fputs("❌ Error: \(error.localizedDescription)\n", stderr)
}
}
}
// MARK: - Click System Menu Extra
@MainActor
struct ClickExtraSubcommand: OutputFormattable {
@Option(help: "Title of the menu extra (e.g., 'WiFi', 'Bluetooth')")
var title: String
@Option(help: "Menu item to click after opening the extra")
var item: String?
@RuntimeStorage private var runtime: CommandRuntime?
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.resolvedRuntime.configuration.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
self.logger.setJsonOutputMode(self.jsonOutput)
do {
let clickResult = try await MenuServiceBridge
.clickMenuBarItem(named: self.title, menu: self.services.menu)
if self.item != nil {
try await Task.sleep(nanoseconds: 200_000_000)
fputs("Warning: Clicking menu items within menu extras is not yet implemented\n", stderr)
}
if self.jsonOutput {
let data = MenuExtraClickResult(
action: "menu_extra_click",
menu_extra: title,
clicked_item: item ?? self.title,
location: clickResult.location.map { ["x": $0.x, "y": $0.y] }
)
outputSuccessCodable(data: data, logger: self.outputLogger)
} else if let clickedItem = item {
print("✓ Clicked '\(clickedItem)' in \(self.title) menu")
} else {
if let location = clickResult.location {
print("✓ Clicked menu extra: \(self.title) at (\(Int(location.x)), \(Int(location.y)))")
} else {
print("✓ Clicked menu extra: \(self.title)")
}
}
} catch let error as MenuError {
self.handleMenuError(error)
throw ExitCode(1)
} catch {
self.handleGenericError(error)
throw ExitCode(1)
}
}
private func handleMenuError(_ error: MenuError) {
if self.jsonOutput {
let errorCode: ErrorCode = switch error {
case .menuBarNotFound:
.MENU_BAR_NOT_FOUND
case .menuItemNotFound:
.MENU_ITEM_NOT_FOUND
case .submenuNotFound:
.MENU_ITEM_NOT_FOUND
case .menuExtraNotFound:
.MENU_ITEM_NOT_FOUND
case .menuItemDisabled:
.INTERACTION_FAILED
case .menuOperationFailed:
.INTERACTION_FAILED
}
outputError(
message: error.localizedDescription,
code: errorCode,
details: "Failed to click menu extra",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleGenericError(_ error: any Error) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .UNKNOWN_ERROR,
details: "Menu extra operation failed",
logger: self.outputLogger
)
} else {
fputs("❌ Error: \(error.localizedDescription)\n", stderr)
}
}
}
// MARK: - List Menu Items
@MainActor
struct ListSubcommand: OutputFormattable, ApplicationResolvablePositional {
@Option(help: "Target application by name, bundle ID, or 'PID:12345'")
var app: String
var positionalAppIdentifier: String { self.app }
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Flag(help: "Include disabled menu items")
var includeDisabled = false
@OptionGroup var focusOptions: FocusCommandOptions
@RuntimeStorage private var runtime: CommandRuntime?
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.resolvedRuntime.configuration.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
self.logger.setJsonOutputMode(self.jsonOutput)
do {
let appIdentifier = try self.resolveApplicationIdentifier()
try await ensureFocusIgnoringMissingWindows(
applicationName: appIdentifier,
options: self.focusOptions,
services: self.services,
logger: self.logger
)
let menuStructure = try await MenuServiceBridge.listMenus(
menu: self.services.menu,
appIdentifier: appIdentifier
)
let filteredMenus = self.includeDisabled ? menuStructure.menus : self
.filterDisabledMenus(menuStructure.menus)
if self.jsonOutput {
let data = MenuListData(
app: menuStructure.application.name,
owner_name: menuStructure.application.name,
bundle_id: menuStructure.application.bundleIdentifier,
menu_structure: self.convertMenusToTyped(filteredMenus)
)
outputSuccessCodable(data: data, logger: self.outputLogger)
} else {
print("Menu structure for \(menuStructure.application.name):")
for menu in filteredMenus {
self.printMenu(menu, indent: 0)
}
}
} catch let error as PeekabooError {
self.handleApplicationError(error)
throw ExitCode(1)
} catch let error as MenuError {
self.handleMenuError(error)
throw ExitCode(1)
} catch {
self.handleGenericError(error)
throw ExitCode(1)
}
}
private func filterDisabledMenus(_ menus: [Menu]) -> [Menu] {
menus.compactMap { menu in
guard menu.isEnabled else { return nil }
let filteredItems = self.filterDisabledItems(menu.items)
return Menu(title: menu.title, items: filteredItems, isEnabled: menu.isEnabled)
}
}
private func filterDisabledItems(_ items: [MenuItem]) -> [MenuItem] {
items.compactMap { item in
guard item.isEnabled else { return nil }
let filteredSubmenu = self.filterDisabledItems(item.submenu)
return MenuItem(
title: item.title,
keyboardShortcut: item.keyboardShortcut,
isEnabled: item.isEnabled,
isChecked: item.isChecked,
isSeparator: item.isSeparator,
submenu: filteredSubmenu,
path: item.path
)
}
}
private func convertMenusToTyped(_ menus: [Menu]) -> [MenuData] {
menus.map { menu in
MenuData(
title: menu.title,
bundle_id: menu.bundleIdentifier,
owner_name: menu.ownerName,
enabled: menu.isEnabled,
items: menu.items.isEmpty ? nil : self.convertMenuItemsToTyped(menu.items)
)
}
}
private func convertMenuItemsToTyped(_ items: [MenuItem]) -> [MenuItemData] {
items.map { item in
MenuItemData(
title: item.title,
bundle_id: item.bundleIdentifier,
owner_name: item.ownerName,
enabled: item.isEnabled,
shortcut: item.keyboardShortcut?.displayString,
checked: item.isChecked ? true : nil,
separator: item.isSeparator ? true : nil,
items: item.submenu.isEmpty ? nil : self.convertMenuItemsToTyped(item.submenu)
)
}
}
private func printMenu(_ menu: Menu, indent: Int) {
let spacing = String(repeating: " ", count: indent)
var line = "\(spacing)\(menu.title)"
if !menu.isEnabled {
line += " (disabled)"
}
print(line)
for item in menu.items {
self.printMenuItem(item, indent: indent + 1)
}
}
private func printMenuItem(_ item: MenuItem, indent: Int) {
let spacing = String(repeating: " ", count: indent)
if item.isSeparator {
print("\(spacing)---")
return
}
var line = "\(spacing)\(item.title)"
if !item.isEnabled {
line += " (disabled)"
}
if item.isChecked {
line += " ✓"
}
if let shortcut = item.keyboardShortcut {
line += " [\(shortcut.displayString)]"
}
print(line)
for subitem in item.submenu {
self.printMenuItem(subitem, indent: indent + 1)
}
}
private func handleApplicationError(_ error: PeekabooError) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .APP_NOT_FOUND,
details: "Application not found",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleMenuError(_ error: MenuError) {
if self.jsonOutput {
let errorCode: ErrorCode = switch error {
case .menuBarNotFound:
.MENU_BAR_NOT_FOUND
case .menuItemNotFound:
.MENU_ITEM_NOT_FOUND
case .submenuNotFound:
.MENU_ITEM_NOT_FOUND
case .menuExtraNotFound:
.MENU_ITEM_NOT_FOUND
case .menuItemDisabled:
.INTERACTION_FAILED
case .menuOperationFailed:
.INTERACTION_FAILED
}
outputError(
message: error.localizedDescription,
code: errorCode,
details: "Failed to list menus",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleGenericError(_ error: any Error) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .UNKNOWN_ERROR,
details: "Menu list operation failed",
logger: self.outputLogger
)
} else {
fputs("❌ Error: \(error.localizedDescription)\n", stderr)
}
}
}
// MARK: - List All Menu Bar Items
@MainActor
struct ListAllSubcommand: OutputFormattable {
@Flag(help: "Include disabled menu items")
var includeDisabled = false
@Flag(help: "Include item frames (pixel positions)")
var includeFrames = false
@RuntimeStorage private var runtime: CommandRuntime?
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.resolvedRuntime.configuration.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
self.logger.setJsonOutputMode(self.jsonOutput)
do {
let frontmostMenus = try await MenuServiceBridge.listFrontmostMenus(menu: self.services.menu)
let menuExtras = try await MenuServiceBridge.listMenuExtras(menu: self.services.menu)
let filteredMenus = self.includeDisabled ? frontmostMenus.menus : self
.filterDisabledMenus(frontmostMenus.menus)
if self.jsonOutput {
struct MenuAllResult: Codable {
let apps: [AppMenuInfo]
struct AppMenuInfo: Codable {
let appName: String
let bundleId: String
let pid: Int32
let menus: [MenuData]
let statusItems: [StatusItem]?
}
struct StatusItem: Codable {
let type: String
let title: String
let enabled: Bool
let frame: Frame?
struct Frame: Codable {
let x: Double
let y: Double
let width: Int
let height: Int
}
}
}
let statusItems = menuExtras.map { extra in
MenuAllResult.StatusItem(
type: "status_item",
title: extra.title,
enabled: true,
frame: self.includeFrames ? MenuAllResult.StatusItem.Frame(
x: Double(extra.position.x),
y: Double(extra.position.y),
width: 0,
height: 0
) : nil
)
}
let appInfo = MenuAllResult.AppMenuInfo(
appName: frontmostMenus.application.name,
bundleId: frontmostMenus.application.bundleIdentifier ?? "unknown",
pid: frontmostMenus.application.processIdentifier,
menus: self.convertMenusToTyped(filteredMenus),
statusItems: statusItems.isEmpty ? nil : statusItems
)
let outputData = MenuAllResult(apps: [appInfo])
outputSuccessCodable(data: outputData, logger: self.outputLogger)
} else {
print("\n=== \(frontmostMenus.application.name) ===")
for menu in filteredMenus {
self.printMenu(menu, indent: 0)
}
if !menuExtras.isEmpty {
print("\n=== System Menu Extras ===")
for extra in menuExtras {
print(" \(extra.title)")
if self.includeFrames {
print(" Position: (\(Int(extra.position.x)), \(Int(extra.position.y)))")
}
}
}
}
} catch let error as MenuError {
self.handleMenuError(error)
throw ExitCode(1)
} catch {
self.handleGenericError(error)
throw ExitCode(1)
}
}
private func filterDisabledMenus(_ menus: [Menu]) -> [Menu] {
menus.compactMap { menu in
guard menu.isEnabled else { return nil }
let filteredItems = self.filterDisabledItems(menu.items)
return Menu(title: menu.title, items: filteredItems, isEnabled: menu.isEnabled)
}
}
private func filterDisabledItems(_ items: [MenuItem]) -> [MenuItem] {
items.compactMap { item in
guard item.isEnabled else { return nil }
let filteredSubmenu = self.filterDisabledItems(item.submenu)
return MenuItem(
title: item.title,
keyboardShortcut: item.keyboardShortcut,
isEnabled: item.isEnabled,
isChecked: item.isChecked,
isSeparator: item.isSeparator,
submenu: filteredSubmenu,
path: item.path
)
}
}
private func convertMenusToTyped(_ menus: [Menu]) -> [MenuData] {
menus.map { menu in
MenuData(
title: menu.title,
bundle_id: menu.bundleIdentifier,
owner_name: menu.ownerName,
enabled: menu.isEnabled,
items: menu.items.isEmpty ? nil : self.convertMenuItemsToTyped(menu.items)
)
}
}
private func convertMenuItemsToTyped(_ items: [MenuItem]) -> [MenuItemData] {
items.map { item in
MenuItemData(
title: item.title,
bundle_id: item.bundleIdentifier,
owner_name: item.ownerName,
enabled: item.isEnabled,
shortcut: item.keyboardShortcut?.displayString,
checked: item.isChecked ? true : nil,
separator: item.isSeparator ? true : nil,
items: item.submenu.isEmpty ? nil : self.convertMenuItemsToTyped(item.submenu)
)
}
}
private func printMenu(_ menu: Menu, indent: Int) {
let spacing = String(repeating: " ", count: indent)
var line = "\(spacing)\(menu.title)"
if !menu.isEnabled {
line += " (disabled)"
}
print(line)
for item in menu.items {
self.printMenuItem(item, indent: indent + 1)
}
}
private func printMenuItem(_ item: MenuItem, indent: Int) {
let spacing = String(repeating: " ", count: indent)
if item.isSeparator {
print("\(spacing)---")
return
}
var line = "\(spacing)\(item.title)"
if !item.isEnabled {
line += " (disabled)"
}
if item.isChecked {
line += " ✓"
}
if let shortcut = item.keyboardShortcut {
line += " [\(shortcut.displayString)]"
}
print(line)
for subitem in item.submenu {
self.printMenuItem(subitem, indent: indent + 1)
}
}
private func handleMenuError(_ error: MenuError) {
if self.jsonOutput {
let errorCode: ErrorCode = switch error {
case .menuBarNotFound:
.MENU_BAR_NOT_FOUND
case .menuItemNotFound:
.MENU_ITEM_NOT_FOUND
case .submenuNotFound:
.MENU_ITEM_NOT_FOUND
case .menuExtraNotFound:
.MENU_ITEM_NOT_FOUND
case .menuItemDisabled:
.INTERACTION_FAILED
case .menuOperationFailed:
.INTERACTION_FAILED
}
outputError(
message: error.localizedDescription,
code: errorCode,
details: "Failed to list menus",
logger: self.outputLogger
)
} else {
fputs("❌ \(error.localizedDescription)\n", stderr)
}
}
private func handleGenericError(_ error: any Error) {
if self.jsonOutput {
outputError(
message: error.localizedDescription,
code: .UNKNOWN_ERROR,
details: "Menu list operation failed",
logger: self.outputLogger
)
} else {
fputs("❌ Error: \(error.localizedDescription)\n", stderr)
}
}
}
}
// MARK: - Focus Helpers
@MainActor
private func ensureFocusIgnoringMissingWindows(
applicationName: String,
options: any FocusOptionsProtocol,
services: any PeekabooServiceProviding,
logger: Logger
) async throws {
do {
try await ensureFocused(
applicationName: applicationName,
options: options,
services: services
)
} catch let focusError as FocusError {
switch focusError {
case .noWindowsFound:
logger.debug("Skipping focus: no windows found for '\(applicationName)'")
case .windowNotFound, .axElementNotFound:
logger.debug("Skipping focus: window lookup failed for '\(applicationName)': \(focusError)")
default:
throw focusError
}
}
}
@MainActor
private func findMenuItem(
canonicalPath: String,
in menus: [Menu]
) -> MenuItem? {
for menu in menus {
let menuBase = MenuCommand.ClickSubcommand.canonicalizeMenuPath(menu.title)
if menuBase == canonicalPath {
return nil // top-level menu is not a clickable item
}
if let item = findMenuItem(in: menu.items, canonicalPath: canonicalPath) {
return item
}
}
return nil
}
private func findMenuItem(
in items: [MenuItem],
canonicalPath: String
) -> MenuItem? {
for item in items {
if MenuCommand.ClickSubcommand.canonicalizeMenuPath(item.path) == canonicalPath {
return item
}
if let nested = findMenuItem(in: item.submenu, canonicalPath: canonicalPath) {
return nested
}
}
return nil
}
@MainActor
extension MenuCommand.ClickSubcommand {
fileprivate static func canonicalizeMenuPath(_ rawPath: String) -> String {
rawPath
.split(separator: ">")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: " > ")
}
fileprivate func ensureMenuItemEnabled(appIdentifier: String, menuPath: String) async throws {
let structure = try await MenuServiceBridge.listMenus(
menu: self.services.menu,
appIdentifier: appIdentifier
)
let canonical = menuPath
guard let item = findMenuItem(canonicalPath: canonical, in: structure.menus) else {
throw MenuError.menuItemNotFound(canonical)
}
guard item.isEnabled else {
throw MenuError.menuItemDisabled(canonical)
}
}
}
@MainActor
func normalizeMenuSelection(item: String?, path: String?) -> (item: String?, path: String?, convertedFromItem: Bool) {
// Agents historically passed menu paths via --item (e.g. "File > New"), which Commander
// happily accepts but the runtime interpreted as a literal button title. Auto-detect and
// reinterpret those inputs so legacy scripts keep working without special casing.
guard path == nil, let item, item.contains(">") else {
return (item, path, false)
}
return (nil, item, true)
}
// MARK: - Subcommand Conformances
@MainActor
extension MenuCommand.ClickSubcommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "click",
abstract: "Click a menu item"
)
}
}
}
extension MenuCommand.ClickSubcommand: AsyncRuntimeCommand {}
@MainActor
extension MenuCommand.ClickSubcommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.app = try values.requireOption("app", as: String.self)
self.pid = try values.decodeOption("pid", as: Int32.self)
self.item = values.singleOption("item")
self.path = values.singleOption("path")
self.focusOptions = try values.makeFocusOptions()
}
}
@MainActor
extension MenuCommand.ClickExtraSubcommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "click-extra",
abstract: "Click a system menu extra (status bar item)"
)
}
}
}
extension MenuCommand.ClickExtraSubcommand: AsyncRuntimeCommand {}
@MainActor
extension MenuCommand.ClickExtraSubcommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.title = try values.requireOption("title", as: String.self)
self.item = values.singleOption("item")
}
}
@MainActor
extension MenuCommand.ListSubcommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "list",
abstract: "List all menu items for an application"
)
}
}
}
extension MenuCommand.ListSubcommand: AsyncRuntimeCommand {}
@MainActor
extension MenuCommand.ListSubcommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.app = try values.requireOption("app", as: String.self)
self.pid = try values.decodeOption("pid", as: Int32.self)
self.includeDisabled = values.flag("includeDisabled")
self.focusOptions = try values.makeFocusOptions()
}
}
@MainActor
extension MenuCommand.ListAllSubcommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "list-all",
abstract: "List all menu bar items system-wide (including status items)"
)
}
}
}
extension MenuCommand.ListAllSubcommand: AsyncRuntimeCommand {}
@MainActor
extension MenuCommand.ListAllSubcommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.includeDisabled = values.flag("includeDisabled")
self.includeFrames = values.flag("includeFrames")
}
}
// MARK: - Data Structures
struct MenuClickResult: Codable {
let action: String
let app: String
let menu_path: String
let clicked_item: String
}
struct MenuExtraClickResult: Codable {
let action: String
let menu_extra: String
let clicked_item: String
let location: [String: Double]?
}
// Typed menu structures for JSON output
struct MenuListData: Codable {
let app: String
let owner_name: String?
let bundle_id: String?
let menu_structure: [MenuData]
}
struct MenuData: Codable {
let title: String
let bundle_id: String?
let owner_name: String?
let enabled: Bool
let items: [MenuItemData]?
}
struct MenuItemData: Codable {
let title: String
let bundle_id: String?
let owner_name: String?
let enabled: Bool
let shortcut: String?
let checked: Bool?
let separator: Bool?
let items: [MenuItemData]?
}