Merge pull request #1346 from tomermesser/status-bar

feat(skill): add macOS menu bar status indicator
This commit is contained in:
gavrielc
2026-03-25 23:55:47 +02:00
committed by GitHub
2 changed files with 280 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
---
name: add-macos-statusbar
description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only.
---
# Add macOS Menu Bar Status Indicator
Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user
start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar.
**macOS only.** Requires Xcode Command Line Tools (`swiftc`).
## Phase 1: Pre-flight
### Check platform
If not on macOS, stop and tell the user:
> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools).
### Check for swiftc
```bash
which swiftc
```
If not found, tell the user:
> Xcode Command Line Tools are required. Install them by running:
>
> ```bash
> xcode-select --install
> ```
>
> Then re-run `/add-macos-statusbar`.
### Check if already installed
```bash
launchctl list | grep com.nanoclaw.statusbar
```
If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify).
## Phase 2: Compile and Install
### Compile the Swift binary
The source lives in the skill directory. Compile it into `dist/`:
```bash
mkdir -p dist
swiftc -O -o dist/statusbar "${CLAUDE_SKILL_DIR}/add/src/statusbar.swift"
```
This produces a small native binary at `dist/statusbar`.
On macOS Sequoia or later, clear the quarantine attribute so the binary can run:
```bash
xattr -cr dist/statusbar
```
### Create the launchd plist
Determine the absolute project root and home directory:
```bash
pwd
echo $HOME
```
Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values
for `{PROJECT_ROOT}` and `{HOME}`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw.statusbar</string>
<key>ProgramArguments</key>
<array>
<string>{PROJECT_ROOT}/dist/statusbar</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{HOME}</string>
</dict>
<key>StandardOutPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.log</string>
<key>StandardErrorPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.error.log</string>
</dict>
</plist>
```
### Load the service
```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
```
## Phase 3: Verify
```bash
launchctl list | grep com.nanoclaw.statusbar
```
The first column should show a PID (not `-`).
Tell the user:
> The bolt icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service.
>
> - **Green dot** — NanoClaw is running
> - **Red dot** — NanoClaw is stopped
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```

View File

@@ -0,0 +1,147 @@
import AppKit
class StatusBarController: NSObject {
private var statusItem: NSStatusItem!
private var isRunning = false
private var timer: Timer?
private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist"
/// Derive the NanoClaw project root from the binary location.
/// The binary is compiled to {project}/dist/statusbar, so the parent of
/// the parent directory is the project root.
private static let projectRoot: String = {
let binary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
return binary.deletingLastPathComponent().deletingLastPathComponent().path
}()
override init() {
super.init()
setupStatusItem()
isRunning = checkRunning()
updateMenu()
// Poll every 5 seconds to reflect external state changes
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self else { return }
let current = self.checkRunning()
if current != self.isRunning {
self.isRunning = current
self.updateMenu()
}
}
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
if let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "NanoClaw") {
image.isTemplate = true
button.image = image
} else {
button.title = ""
}
button.toolTip = "NanoClaw"
}
}
private func checkRunning() -> Bool {
let task = Process()
task.launchPath = "/bin/launchctl"
task.arguments = ["list", "com.nanoclaw"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
guard (try? task.run()) != nil else { return false }
task.waitUntilExit()
if task.terminationStatus != 0 { return false }
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
// launchctl list output: "PID\tExitCode\tLabel" "-" means not running
let pid = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t").first ?? "-"
return pid != "-"
}
private func updateMenu() {
let menu = NSMenu()
// Status row with colored dot
let statusItem = NSMenuItem()
let dot = ""
let dotColor: NSColor = isRunning ? .systemGreen : .systemRed
let attr = NSMutableAttributedString(string: dot, attributes: [.foregroundColor: dotColor])
let label = isRunning ? "NanoClaw is running" : "NanoClaw is stopped"
attr.append(NSAttributedString(string: label, attributes: [.foregroundColor: NSColor.labelColor]))
statusItem.attributedTitle = attr
statusItem.isEnabled = false
menu.addItem(statusItem)
menu.addItem(NSMenuItem.separator())
if isRunning {
let stop = NSMenuItem(title: "Stop", action: #selector(stopService), keyEquivalent: "")
stop.target = self
menu.addItem(stop)
let restart = NSMenuItem(title: "Restart", action: #selector(restartService), keyEquivalent: "r")
restart.target = self
menu.addItem(restart)
} else {
let start = NSMenuItem(title: "Start", action: #selector(startService), keyEquivalent: "")
start.target = self
menu.addItem(start)
}
menu.addItem(NSMenuItem.separator())
let logs = NSMenuItem(title: "View Logs", action: #selector(viewLogs), keyEquivalent: "")
logs.target = self
menu.addItem(logs)
self.statusItem.menu = menu
}
@objc private func startService() {
run("/bin/launchctl", ["load", plistPath])
refresh(after: 2)
}
@objc private func stopService() {
run("/bin/launchctl", ["unload", plistPath])
refresh(after: 2)
}
@objc private func restartService() {
let uid = getuid()
run("/bin/launchctl", ["kickstart", "-k", "gui/\(uid)/com.nanoclaw"])
refresh(after: 3)
}
@objc private func viewLogs() {
let logPath = "\(StatusBarController.projectRoot)/logs/nanoclaw.log"
NSWorkspace.shared.open(URL(fileURLWithPath: logPath))
}
private func refresh(after seconds: Double) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
guard let self else { return }
self.isRunning = self.checkRunning()
self.updateMenu()
}
}
@discardableResult
private func run(_ path: String, _ args: [String]) -> Int32 {
let task = Process()
task.launchPath = path
task.arguments = args
task.standardOutput = Pipe()
task.standardError = Pipe()
try? task.run()
task.waitUntilExit()
return task.terminationStatus
}
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let controller = StatusBarController()
app.run()