status-icon-01
This commit is contained in:
140
.claude/skills/add-statusbar/SKILL.md
Normal file
140
.claude/skills/add-statusbar/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
name: add-statusbar
|
||||||
|
description: Add a macOS menu bar status indicator for NanoClaw. Shows a ⚡ 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-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 4 (Verify).
|
||||||
|
|
||||||
|
## Phase 2: Apply Code Changes
|
||||||
|
|
||||||
|
### Initialize skills system (if needed)
|
||||||
|
|
||||||
|
If `.nanoclaw/` directory doesn't exist yet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/apply-skill.ts --init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply the skill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx scripts/apply-skill.ts .claude/skills/add-statusbar
|
||||||
|
```
|
||||||
|
|
||||||
|
This copies `src/statusbar.swift` into the project and records the application in `.nanoclaw/state.yaml`.
|
||||||
|
|
||||||
|
## Phase 3: Compile and Install
|
||||||
|
|
||||||
|
### Compile the Swift binary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swiftc -O -o dist/statusbar src/statusbar.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces a small (~55KB) native binary at `dist/statusbar`.
|
||||||
|
|
||||||
|
### Create the launchd plist
|
||||||
|
|
||||||
|
Determine the absolute project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
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 4: Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl list | grep com.nanoclaw.statusbar
|
||||||
|
```
|
||||||
|
|
||||||
|
The first column should show a PID (not `-`).
|
||||||
|
|
||||||
|
Tell the user:
|
||||||
|
|
||||||
|
> The ⚡ 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
|
||||||
|
rm src/statusbar.swift
|
||||||
|
```
|
||||||
139
.claude/skills/add-statusbar/add/src/statusbar.swift
Normal file
139
.claude/skills/add-statusbar/add/src/statusbar.swift
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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 = "\(NSHomeDirectory())/Documents/Projects/nanoclaw/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()
|
||||||
10
.claude/skills/add-statusbar/manifest.yaml
Normal file
10
.claude/skills/add-statusbar/manifest.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
skill: statusbar
|
||||||
|
version: 1.0.0
|
||||||
|
description: "macOS menu bar status indicator — shows NanoClaw running state with start/stop/restart controls"
|
||||||
|
core_version: 0.1.0
|
||||||
|
adds:
|
||||||
|
- src/statusbar.swift
|
||||||
|
modifies: []
|
||||||
|
structured: {}
|
||||||
|
conflicts: []
|
||||||
|
depends: []
|
||||||
Reference in New Issue
Block a user