From 32dda34af49f8d70c188860f87961fe947bafa43 Mon Sep 17 00:00:00 2001 From: tomermesser Date: Sun, 8 Mar 2026 16:38:03 +0200 Subject: [PATCH] status-icon-01 --- .claude/skills/add-statusbar/SKILL.md | 140 ++++++++++++++++++ .../add-statusbar/add/src/statusbar.swift | 139 +++++++++++++++++ .claude/skills/add-statusbar/manifest.yaml | 10 ++ 3 files changed, 289 insertions(+) create mode 100644 .claude/skills/add-statusbar/SKILL.md create mode 100644 .claude/skills/add-statusbar/add/src/statusbar.swift create mode 100644 .claude/skills/add-statusbar/manifest.yaml diff --git a/.claude/skills/add-statusbar/SKILL.md b/.claude/skills/add-statusbar/SKILL.md new file mode 100644 index 0000000..c0f343c --- /dev/null +++ b/.claude/skills/add-statusbar/SKILL.md @@ -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 + + + + + Label + com.nanoclaw.statusbar + ProgramArguments + + {PROJECT_ROOT}/dist/statusbar + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + HOME + {HOME} + + StandardOutPath + {PROJECT_ROOT}/logs/statusbar.log + StandardErrorPath + {PROJECT_ROOT}/logs/statusbar.error.log + + +``` + +### 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 +``` diff --git a/.claude/skills/add-statusbar/add/src/statusbar.swift b/.claude/skills/add-statusbar/add/src/statusbar.swift new file mode 100644 index 0000000..6fff79a --- /dev/null +++ b/.claude/skills/add-statusbar/add/src/statusbar.swift @@ -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() diff --git a/.claude/skills/add-statusbar/manifest.yaml b/.claude/skills/add-statusbar/manifest.yaml new file mode 100644 index 0000000..0d7d720 --- /dev/null +++ b/.claude/skills/add-statusbar/manifest.yaml @@ -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: []