agentleFS
tarikhamiltontarikhamilton/claude-skills/skills/streamdeck-workspace-button/SKILL.md

streamdeck-workspace-button

Skill0 starsChanged 5 months agoMITView on GitHub

Create a Stream Deck button that opens an AeroSpace workspace with specific apps in a chosen layout. Use when the user says "new Stream Deck button", "Stream Deck workspace button", "add a Stream Deck shortcut for X", "make a Stream Deck button that opens X in workspace Y", or describes wanting a physical key that switches to a focused workspace with specific apps tiled a specific way. Builds the bash script, the silent `.app` wrapper, fetches the icon, and edits `~/.config/aerospace/aerospace.toml` if needed.

  • Deletes or force-pushes

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    If the new button is one Cursor project + the default browser, project-workspace.sh already implements this pattern — bind the chord …

    line 57

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Stream Deck → AeroSpace workspace button

You're setting up a Stream Deck key that, when pressed, focuses an AeroSpace workspace and ensures specific apps are present in a specific layout. This skill handles the on-disk pieces. The Stream Deck button-side wiring (action selection, icon drop) is GUI-only unless the user quits Stream Deck and lets you patch manifest.json directly via set-streamdeck-button.sh.

Helper scripts on Tarik's machine

The scripts under ~/.config/aerospace/scripts/ do the repetitive on-disk work. Read each script's --help cold; they're self-documenting.

  • build-aerospace-wrapper.sh — osacompile + PlistBuddy to produce a silent .app at ~/.config/aerospace/apps/<Name>.app.
  • set-streamdeck-button.sh — patch one button (title / icon / action) in a profile manifest. Refuses to run while Stream Deck is up if the target is the live profile.
  • inventory-streamdeck-buttons.sh — TSV of every button in the active profile manifest.
  • diagnose-streamdeck-aerospace.sh — health check (daemon, config freshness, permissions, manifest discovery).

build-aerospace-wrapper.sh exists because Stream Deck's "System → Open" path takes a .app and the right shape is finicky: LSUIElement=true (else the dock icon flashes on every press) plus a stable CFBundleIdentifier. The script collapses both into one invocation and overwrites cleanly on re-run.

Conventions on Tarik's machine

These match the existing setup — assume true unless evidence says otherwise:

  • AeroSpace config at ~/.config/aerospace/aerospace.toml. Modifier is alt. Workspaces 1-10 keybound to alt-q through alt-f.
  • AeroSpace daemon socket at /tmp/bobko.aerospace-tiki.sock. Errors land at /tmp/bobko.aerospace/aerospace-runtime-error.txt.
  • Scripts: ~/.config/aerospace/scripts/<name>.sh (mirrors project-workspace.sh, toggle-iterm.sh).
  • Silent app wrappers: ~/.config/aerospace/apps/<Name>.app.
  • Icons: ~/.config/aerospace/icons/<name>.png (192×192 ideal).
  • Cursor CLI: ~/.local/bin/cursor. Default browser: Chrome (com.google.Chrome). Cursor bundle id: com.todesktop.230313mzl4w4u92.
  • jq is available system-wide.

Required inputs from the user (ask if missing)

  1. Slug — kebab-case identifier (e.g. whipsocial, urm-dashboard). Drives the script + app + icon filenames.
  2. Workspace number — 1–10. If the requested keybind is currently bound to a different workspace's auto-detect rules, propose moving the old rule to a blank workspace (5, 7, 8, 9 are usually blank — verify via ~/.config/aerospace/aerospace.toml).
  3. App list with layout intent — e.g. "Chrome left, Cursor on ~/Code/whipsocial right". For each app, capture: bundle id (or app name), whether it's a project-scoped Cursor window (and the project dir), and tile position (left/right; encoded by launch order in horizontal tiles).
  4. Icon source — URL or local path. If they say "use the brand favicon," fetch <domain>/icon-192x192.png, falling back through the link tags in the site's <head> (apple-touch-icon, icon).

Steps

1. Verify nothing's already there

Read ~/.config/aerospace/aerospace.toml. If the target workspace already has on-window-detected rules, surface them and confirm with the user whether to keep, remove, or relocate. Pattern for relocation: change the offending rule's move-node-to-workspace to a blank workspace number, and add a header comment marking the moved-to workspace's new purpose.

2. Write the bash script

Path: ~/.config/aerospace/scripts/<slug>-workspace.sh.

Yank policy — this is the critical design choice. Per-app, decide:

  • Ambient apps (Chrome, Slack, Spotify, terminals, anything with many windows where the user moves them around freely): never yank. If no window of that app is on the target workspace, open a NEW window — don't drag an existing window away from wherever the user put it. Mechanism: snapshot all window-ids before creating, diff after, the new id is "ours."
  • Project-scoped windows (Cursor on a specific project, a browser pointed at a specific dev URL, anything title-uniquely identifying a singleton window): yank. There's only one logical window per project, and the target workspace's whole purpose is to host it, so dragging it back is correct.

The asymmetry matches the user's mental model. Yanking a Chrome window away from a workspace they put it on feels destructive; yanking the "whipsocial" Cursor window back to its home feels helpful.

If the new button is one Cursor project + the default browser, project-workspace.sh already implements this pattern — bind the chord directly to it instead of writing a new script:

cmd-ctrl-alt-<key> = 'exec-and-forget ~/.config/aerospace/scripts/project-workspace.sh <ws> <cursor-path> <title-match>'

For setups that don't fit that mold (three+ apps, a non-Cursor project window, custom tile rules), write a new script. Template:

#!/usr/bin/env bash
# Stream Deck entrypoint. Focus workspace <N> with <ambient app> (left) and
# <project app> (right) tiled horizontally.
#
# Yank policy:
#   - <ambient app>: ambient, never yank. If none on ws <N>, open new.
#   - <project app>: project-scoped (unique title), yank back if strayed.
set -uo pipefail

WORKSPACE=<N>
AMBIENT_BUNDLE="<bundle id>"
AMBIENT_DISPLAY_NAME="<app display name, e.g. Google Chrome>"
PROJECT_BUNDLE="<bundle id>"
PROJECT_TITLE_MATCH="<substring>"
PROJECT_DIR="<absolute path>"   # for Cursor; remove for non-Cursor

wait_for_window() {
    local label="$1" matcher="$2" tries=30 id=""
    while [ "$tries" -gt 0 ]; do
        id="$("$matcher" | head -n1)"
        [ -n "$id" ] && { echo "$id"; return 0; }
        sleep 0.1; tries=$((tries - 1))
    done
    echo "$label: timed out waiting for a window" >&2
    return 1
}

all_ambient_ids() {
    aerospace list-windows --monitor all --app-bundle-id "$AMBIENT_BUNDLE" \
        --format '%{window-id}' 2>/dev/null | sort
}

ambient_on_workspace() {
    aerospace list-windows --workspace "$WORKSPACE" --app-bundle-id "$AMBIENT_BUNDLE" \
        --format '%{window-id}' 2>/dev/null
}

match_project_window() {
    aerospace list-windows --monitor all --app-bundle-id "$PROJECT_BUNDLE" --json 2>/dev/null \
        | jq -r --arg needle "$PROJECT_TITLE_MATCH" '
            .[] | select((."window-title" // "") | test($needle; "i"))
            | ."window-id"
        '
}

aerospace workspace "$WORKSPACE"

# Ambient: never yank. If none on ws <N>, open a new window.
if [ -z "$(ambient_on_workspace | head -n1 || true)" ]; then
    before="$(all_ambient_ids)"
    if pgrep -x "$AMBIENT_DISPLAY_NAME" >/dev/null; then
        osascript -e "tell application \"$AMBIENT_DISPLAY_NAME\" to make new window" >/dev/null 2>&1
    else
        open -a "$AMBIENT_DISPLAY_NAME"
    fi
    tries=30 new_id=""
    while [ "$tries" -gt 0 ]; do
        after="$(all_ambient_ids)"
        new_id="$(comm -13 <(echo "$before") <(echo "$after") | head -n1)"
        [ -n "$new_id" ] && break
        sleep 0.1; tries=$((tries - 1))
    done
    [ -n "$new_id" ] && aerospace move-node-to-workspace --window-id "$new_id" "$WORKSPACE" 2>/dev/null || true
fi

# Project-scoped: yank back if it exists anywhere, else launch.
project_id="$(match_project_window | head -n1 || true)"
if [ -z "$project_id" ]; then
    "$HOME/.local/bin/cursor" "$PROJECT_DIR"
    project_id="$(wait_for_window "$AMBIENT_DISPLAY_NAME" match_project_window || true)"
fi
if [ -n "$project_id" ]; then
    aerospace move-node-to-workspace --window-id "$project_id" "$WORKSPACE" 2>/dev/null || true
    aerospace focus --window-id "$project_id" 2>/dev/null || true
fi

aerospace layout tiles horizontal 2>/dev/null || true

Notes when filling the template:

  • set -uo pipefail not -euo — the script needs to continue past missing windows.
  • Insertion order = tile order in horizontal layout: the first window placed lands on the left. Ambient app is added first (always to ws N, either pre-existing or fresh), project window second.
  • Don't make AppleScript's make new window the default for everything — some apps' AppleScript dictionaries don't have a make new window verb (or it does something unexpected). When in doubt, test with osascript -e 'tell application "X" to make new window' interactively before locking it into the script.
  • For a third+ app, copy the ambient or project block as appropriate.

chmod +x the script. Sanity-check with bash -n <script>.

3. Build the silent .app wrapper

~/.config/aerospace/scripts/build-aerospace-wrapper.sh \
  --name <CamelCaseName>Workspace \
  --command "$HOME/.config/aerospace/scripts/<slug>-workspace.sh"

Prints the .app path on stdout. LSUIElement=true and the bundle id are set automatically. Re-running with the same --name replaces the wrapper in place.

4. Add the .icns icon to the .app (Finder face)

Source PNG must be square, ≥192×192. ~/.config/aerospace/icons/<slug>.png.

ICONSRC="$HOME/.config/aerospace/icons/<slug>.png"
APP="$HOME/.config/aerospace/apps/<CamelCaseName>Workspace.app"
ICONDST="$APP/Contents/Resources/applet.icns"
ICONSET=/tmp/<slug>.iconset
rm -rf "$ICONSET"; mkdir -p "$ICONSET"
for size in 16 32 64 128 256 512; do
  sips -z $size $size "$ICONSRC" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1
  sips -z $((size*2)) $((size*2)) "$ICONSRC" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null 2>&1
done
iconutil -c icns "$ICONSET" -o "$ICONDST"

This replaces the default osacompile applet icon. The Stream Deck button face is set separately (step 6) — that uses the PNG directly.

5. Fetch icon if the user didn't supply one

For brand icons from a website:

curl -sL --max-time 10 https://<domain>/ \
  | grep -oE '<link[^>]*(icon|apple-touch)[^>]*>' \
  | head

Pick the highest-res href, then curl -fSL to ~/.config/aerospace/icons/<slug>.png. Verify with file <path> that the result is actually PNG image data — many Next.js / SPA hosts return their HTML shell for any unknown path.

6. Reload AeroSpace + sanity-check

aerospace reload-config

Then verify with aerospace list-workspaces --all. If the daemon hangs after reload-config (commands stop responding), check /tmp/bobko.aerospace/aerospace-runtime-error.txt. Known 0.20.3-Beta bug: lhs: AppBundle.Workspace rhs: AppBundle.Workspace from updateTrayText. Recovery is kill <aerospace-pid> && open -a AeroSpace. Warn the user before killing — it relayouts all their open windows.

7. Test-run the script directly

~/.config/aerospace/scripts/<slug>-workspace.sh
aerospace list-windows --workspace <N> --format '%{app-name} | %{window-title}'

Re-run a second time to confirm idempotency — should not duplicate windows.

8. Hand off the Stream Deck wiring

Two paths — GUI is what Tarik typically uses; CLI is available when he wants a button created at a specific coord without quitting Stream Deck's UI.

GUI:

  1. In Stream Deck app, drag System → Open onto a key.
  2. App / File: $HOME/.config/aerospace/apps/<CamelCaseName>Workspace.app (use the absolute path — Stream Deck won't expand ~)
  3. Drag $HOME/.config/aerospace/icons/<slug>.png onto the key as the icon.
  4. Leave title empty.
  5. First press may trigger a macOS permissions prompt for Automation (Shortcuts) — allow once, sticks forever.

CLI (Stream Deck must be quit first):

pkill -f "/Applications/Elgato Stream Deck.app/Contents/MacOS/Stream Deck"
~/.config/aerospace/scripts/set-streamdeck-button.sh \
  --coord <r,c> \
  --action "$HOME/.config/aerospace/apps/<CamelCaseName>Workspace.app" \
  --icon "$HOME/.config/aerospace/icons/<slug>.png"
open -a "Elgato Stream Deck"

Add --create if the target coord doesn't exist yet in the manifest (greenfield profile or empty slot). With --create, the script seeds Actions = {} if null and stubs a fresh ActionID + States[{}] for the coord before applying edits. Without it, the script errors out when the coord is missing.

set-streamdeck-button.sh refuses to run if Stream Deck is up and the target manifest is the live profile — quit it first, otherwise the patch gets clobbered on Stream Deck's next in-memory save.

Failure modes worth catching

  • AeroSpace daemon hang after reload-config — see step 6 recovery.
  • Cursor CLI moves a NEW window into ws N but an old whipsocial window already existed and stays on its old workspace — the title-match in match_app_b would pick up the old window (move it correctly), not the new one. Confirm by running the script twice; the second run should find the moved window via title match and refocus without opening a new one.
  • Brand favicon fetch returns SPA HTML — file <result> check catches it. Fall back to inspecting <link> tags in the homepage <head>.
  • .app quarantine on first Stream Deck press — locally-created .apps usually aren't quarantined, but if it is, xattr -d com.apple.quarantine <path> clears it.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.