Merge pull request #1346 from tomermesser/status-bar
feat(skill): add macOS menu bar status indicator
This commit is contained in:
133
.claude/skills/add-macos-statusbar/SKILL.md
Normal file
133
.claude/skills/add-macos-statusbar/SKILL.md
Normal 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
|
||||
```
|
||||
147
.claude/skills/add-macos-statusbar/add/src/statusbar.swift
Normal file
147
.claude/skills/add-macos-statusbar/add/src/statusbar.swift
Normal 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()
|
||||
Reference in New Issue
Block a user