Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devskim.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"Globs": ["**/.git/**", "**/megalinter-reports/**"],
"IgnoreRuleIds": ["DS162092"]
"IgnoreRuleIds": ["DS162092", "DS137138"]
}
2 changes: 1 addition & 1 deletion .mega-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ DISABLE_LINTERS:

SARIF_REPORTER: true

LUA_LUACHECK_ARGUMENTS: "--globals vim"
LUA_LUACHECK_ARGUMENTS: "--globals vim hs"

CSS_STYLELINT_FILTER_REGEX_EXCLUDE: "(yasb/styles\\.css)"

Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ Finicky's built-in auto-reload does NOT work when the config is managed by chezm

---

## Hammerspoon (macOS Spaces)

Hammerspoon provides direct macOS Space switching: `⌥1`–`⌥5` (Option, matching Komorebi's `alt`+number on Windows) jump to the Nth Space on the focused screen, including full-screen-app spaces, and create a new empty Space when the target position does not exist yet. Cask in `home/.chezmoidata/brew/00-base.yaml` (macOS-gated); config at `home/dot_hammerspoon/{init.lua,spaces.lua,space_indicator.lua}` (→ `~/.hammerspoon/`), gated to darwin in `home/.chezmoiignore`. A menu bar badge (`space_indicator.lua`) shows the active Space's position. Gotchas that have bitten us:

- **Bind number-row Space switches with `hs.eventtap`, not `hs.hotkey.bind`.** The keys are `⌥1`..`⌥5` (Option, matching Komorebi's `alt`+number). Two reasons the tap is needed: (1) on macOS `⌥`+number normally types special characters (`¡™£¢∞`), and the tap returns `true` to swallow the keystroke before the input system inserts the character, so the Space switches and nothing is typed (verified by reading a focused text field after a synthetic `⌥1`); (2) `⌃`+number can't go through `hs.hotkey` at all: macOS reserves `⌃1`..`⌃N` as Carbon hotkeys for "Switch to Desktop" by the number of Mission Control *user* desktops even when the System Settings checkboxes are off, so `RegisterEventHotKey` rejects them (`hs.hotkey:enable()` returns nil, console logs `-9878 ... already registered`). Diagnose hotkey issues with `hs -c 'return #hs.hotkey.getHotkeys()'` plus the Hammerspoon console.
- **`hs.spaces` reaches full-screen spaces.** `hs.spaces.spacesForScreen(uuid)` lists every Space (user and full screen/tiled, per `hs.spaces.spaceType`) in Mission Control order, and `hs.spaces.gotoSpace(id)` activates one by driving its Mission Control thumbnail, so it can land on full-screen spaces (the native `⌃`+number shortcuts cannot). Map "Space N" by indexing that list by position.
- **Space positions are not stable while "rearrange by most recent use" is on.** With `com.apple.dock` `mru-spaces` unset or true (the macOS default), visiting a full-screen space reorders it in Mission Control, so the index `⌥N` and the menu bar indicator use drifts (the order is stable only while idle). `defaults write com.apple.dock mru-spaces -bool false && killall Dock` gives stable positions; this is applied declaratively by `home/.chezmoiscripts/run_onchange_after_macos_defaults.sh.tmpl` (darwin-guarded, restarts the Dock only when it is running). The indicator (`hs.menubar` badge driven by `hs.spaces.watcher`) recomputes ~0.25s after a change so it reads the settled order, not a mid-transition one; the watcher fires before `activeSpaceOnScreen` settles otherwise.
- **Auto-reload works**, unlike Finicky above: `hs.pathwatcher` uses FSEvents (path-based), so it survives chezmoi's atomic-rename writes (new inode each apply).
- **`hs.ipc` is enabled** in `init.lua`, so the running config is queryable from the `hs` CLI (`hs -c '...'`), the main way to debug Spaces and hotkeys.
- **Requires Accessibility** permission (System Settings → Privacy & Security → Accessibility); `gotoSpace` drives Mission Control through the Accessibility API. One-time manual grant, cannot be automated by chezmoi.

---

## Rancher Desktop / Docker (Windows)

- Rancher Desktop provides the Docker daemon on Windows via a WSL2 backend (`rancher-desktop` distro).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Opinionated dotfiles managed with [chezmoi](https://chezmoi.io). One repo config
| **Homebrew** | Package manager | x | x | | x |
| **mise** | Runtime manager (node, python, go, etc.) | x | x | x | x |
| **Finicky** | Default browser router | x | | | |
| **Hammerspoon** | Direct macOS Space switching | x | | | |
| **Steam** | Game platform (Windows) | | | x | |
| **Sysinternals** | Windows diagnostics utilities | | | x | |
| **GlazeWM** | Windows tiling window manager | | | x | |
Expand Down
4 changes: 4 additions & 0 deletions home/.chezmoidata/brew/00-base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ brew:
name: ghostty
conditions:
- { kind: os, op: is, value: mac }
- type: cask
name: hammerspoon # lua desktop automation (direct Space switching)
conditions:
- { kind: os, op: is, value: mac }
- type: cask
name: jordanbaird-ice # menu bar
conditions:
Expand Down
8 changes: 4 additions & 4 deletions home/.chezmoidata/brew/10-chipwolf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ brew:
# name: slack
# conditions:
# - { kind: os, op: is, value: mac }
# - type: cask
# name: yubico-authenticator
# conditions:
# - { kind: os, op: is, value: mac }
- type: cask
name: yubico-authenticator
conditions:
- { kind: os, op: is, value: mac }
# - type: cask
# name: zoom
# conditions:
Expand Down
1 change: 1 addition & 0 deletions home/.chezmoiignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Documents
.config/tmux
.config/ghostty
.config/finicky.js
.hammerspoon
Library
{{ end }}

Expand Down
20 changes: 20 additions & 0 deletions home/.chezmoiscripts/run_onchange_after_macos_defaults.sh.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{{ if eq .chezmoi.os "darwin" -}}
#!/usr/bin/env bash
# Apply macOS system defaults the dotfiles depend on.
# run_onchange: chezmoi hashes this script's rendered content and re-runs it
# only when the content below changes, so it is not re-applied every apply.
set -euo pipefail

# Keep Mission Control Space positions stable: do not reorder Spaces by most
# recent use. This keeps the Hammerspoon Ctrl+1..Ctrl+5 direct-Space switches
# and the menu bar Space indicator (see home/dot_hammerspoon/) pointing at
# consistent positions, including full-screen-app spaces.
defaults write com.apple.dock mru-spaces -bool false

# Restart the Dock so the change takes effect immediately, but only when it is
# actually running (it is not in headless/CI sessions, where there is nothing
# to restart and the write above already persisted).
if pgrep -x Dock >/dev/null 2>&1; then
killall Dock
fi
{{ end -}}
34 changes: 34 additions & 0 deletions home/dot_hammerspoon/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- ~/.hammerspoon/init.lua
-- Managed by chezmoi (source: home/dot_hammerspoon/init.lua). Do not edit directly.
--
-- Hammerspoon configuration. Currently this only sets up direct macOS Space
-- switching; see spaces.lua for the keybindings and rationale.

-- configWatcher is intentionally a non-local global so the pathwatcher below is
-- not garbage-collected; declare it for luacheck (the hs runtime global is
-- allowed via LUA_LUACHECK_ARGUMENTS in .mega-linter.yml).
-- luacheck: globals configWatcher

-- Enable the `hs` command-line tool (talks to this instance over a message
-- port) so the config can be queried/scripted from a shell.
require("hs.ipc")

require("spaces")
require("space_indicator")

-- Auto-reload the configuration whenever any .lua file under ~/.hammerspoon
-- changes. hs.pathwatcher uses FSEvents, which is path-based, so it survives
-- chezmoi's atomic writes (new inode on every apply) unlike inode-based
-- watchers. Keep the watcher in a global so it is not garbage collected.
local function reloadConfig(paths)
for _, file in ipairs(paths) do
if file:sub(-4) == ".lua" then
hs.reload()
return
end
end
end

configWatcher = hs.pathwatcher.new(hs.configdir, reloadConfig):start()

hs.alert.show("Hammerspoon config loaded")
81 changes: 81 additions & 0 deletions home/dot_hammerspoon/space_indicator.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- ~/.hammerspoon/space_indicator.lua
-- Managed by chezmoi (source: home/dot_hammerspoon/space_indicator.lua). Do not edit directly.
--
-- Menu bar indicator showing which Space is currently active on the focused
-- screen: one dot per Space, the current one filled (●) and the rest hollow
-- (○), in Mission Control order (the same order Option+1..Option+5 use, see
-- spaces.lua). Full-screen-app spaces are counted too. The dropdown lists every
-- Space; click one to jump.
--
-- Space changes are detected with hs.spaces.watcher. The badge is recomputed
-- from hs.spaces.spacesForScreen + activeSpaceOnScreen rather than trusting the
-- watcher's (deprecated) space-number argument.

-- spaceIndicatorMenubar and spaceIndicatorWatcher are intentionally non-local
-- globals so the menu bar item and watcher are not garbage-collected; declare
-- them for luacheck (the hs runtime global is allowed via .mega-linter.yml).
-- luacheck: globals spaceIndicatorMenubar spaceIndicatorWatcher

-- Keep the menu bar item in a global so it is not garbage collected.
spaceIndicatorMenubar = hs.menubar.new()

local function focusedScreenSpaces()
local screen = hs.screen.mainScreen()
if not screen then
return nil
end
local uuid = screen:getUUID()
return uuid, hs.spaces.spacesForScreen(uuid), hs.spaces.activeSpaceOnScreen(uuid)
end

-- One dot per Space on the focused screen, the active one filled.
local function updateIndicator()
if not spaceIndicatorMenubar then
return
end
local _, spaces, active = focusedScreenSpaces()
if not spaces or #spaces == 0 then
spaceIndicatorMenubar:setTitle("•")
return
end
local dots = {}
for i, id in ipairs(spaces) do
dots[i] = (id == active) and "●" or "○"
end
spaceIndicatorMenubar:setTitle(table.concat(dots))
end

if spaceIndicatorMenubar then
-- Dropdown: every Space on the focused screen, current one checked, click to jump.
spaceIndicatorMenubar:setMenu(function()
local _, spaces, active = focusedScreenSpaces()
local items = {}
if spaces then
for i, id in ipairs(spaces) do
local suffix = (hs.spaces.spaceType(id) == "fullscreen") and " (full screen)" or ""
items[#items + 1] = {
title = "Space " .. i .. suffix,
checked = (id == active),
fn = function()
hs.spaces.gotoSpace(id)
end,
}
end
else
items[#items + 1] = { title = "Spaces unavailable", disabled = true }
end
return items
end)
end

-- Recompute when the active Space changes: covers Ctrl+number, trackpad swipes,
-- and entering/leaving full-screen apps. The recompute is deferred briefly so it
-- reads once macOS has finished the transition and any most-recent-use
-- reordering, otherwise activeSpaceOnScreen can still report the old Space.
-- Keep the watcher in a global too.
spaceIndicatorWatcher = hs.spaces.watcher.new(function()
hs.timer.doAfter(0.25, updateIndicator)
end)
spaceIndicatorWatcher:start()

updateIndicator()
106 changes: 106 additions & 0 deletions home/dot_hammerspoon/spaces.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
-- ~/.hammerspoon/spaces.lua
-- Managed by chezmoi (source: home/dot_hammerspoon/spaces.lua). Do not edit directly.
--
-- Jump directly to the Nth Space on the focused screen with Option+1 .. Option+5
-- (matching Komorebi's alt+number Workspace switches on Windows), INCLUDING
-- full-screen-app spaces. If a position does not exist yet an empty Space is
-- created and focused (see gotoSpaceIndex below).
--
-- Why an event tap rather than hs.hotkey.bind:
-- On macOS, Option+1 .. Option+5 normally type the special characters
-- ¡ ™ £ ¢ ∞. The event tap returns true to swallow the keystroke before the
-- input system translates it, so the Space switches and no character is
-- inserted. (An event tap is also the only workable approach for Ctrl+number,
-- which we do not use: macOS reserves Ctrl+1 .. Ctrl+N as Carbon hotkeys for
-- "Switch to Desktop" based on the number of Mission Control user desktops
-- even when the System Settings checkboxes are off, so RegisterEventHotKey
-- rejects them with -9878 and hs.hotkey.bind cannot register them.)
--
-- Why this reaches full-screen spaces:
-- hs.spaces.spacesForScreen() lists every Space (user and full screen / tiled,
-- per hs.spaces.spaceType) in Mission Control order, and hs.spaces.gotoSpace()
-- activates a Space by driving its Mission Control thumbnail button, which can
-- land on full-screen spaces. The native shortcuts cannot.
-- Docs: https://www.hammerspoon.org/docs/hs.spaces.html
--
-- To extend, raise spaceCount (and add the matching Spaces in Mission Control).

local spaceCount = 5

-- Map the physical keycodes for "1".."spaceCount" to their 1-based index.
local codeToIndex = {}
for i = 1, spaceCount do
local code = hs.keycodes.map[tostring(i)]
if code then
codeToIndex[code] = i
end
end

-- Switch to the index-th (1-based) Space on the screen that currently has focus.
-- If that position does not exist yet, create a new empty Space and switch to
-- it rather than doing nothing.
local function gotoSpaceIndex(index)
local screen = hs.screen.mainScreen()
if not screen then
return
end
local uuid = screen:getUUID()

local spaces = hs.spaces.spacesForScreen(uuid)
if not spaces then
hs.alert.show("Spaces: could not read Spaces for the focused screen")
return
end

local target = spaces[index]
if target then
hs.spaces.gotoSpace(target)
return
end

-- Position index does not exist yet: add an empty Space and go to it.
-- addSpaceToScreen does not return the new id, and a new desktop is not always
-- appended last, so diff the Space list before and after to find it.
local existed = {}
for _, id in ipairs(spaces) do
existed[id] = true
end

local ok, err = hs.spaces.addSpaceToScreen(uuid)
if not ok then
hs.alert.show("Could not add a Space: " .. tostring(err))
return
end

local updated = hs.spaces.spacesForScreen(uuid)
if updated then
for _, id in ipairs(updated) do
if not existed[id] then
hs.spaces.gotoSpace(id)
return
end
end
end
end

-- Keep the tap in a global so it is not garbage collected.
-- luacheck: globals spaceSwitchTap
spaceSwitchTap = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(event)
-- Option must be the only modifier, so Option+Shift+1 and friends pass through.
if not event:getFlags():containExactly({ "alt" }) then
return false
end

local index = codeToIndex[event:getKeyCode()]
if not index then
return false
end

-- Defer the switch so the Mission Control animation does not block the tap.
hs.timer.doAfter(0, function()
gotoSpaceIndex(index)
end)
return true -- swallow the keystroke
end)

spaceSwitchTap:start()
6 changes: 3 additions & 3 deletions skills/agent-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Each client requires a different trick because none expose "dump my system promp
| Claude Code | Loopback HTTP server bound to a random port via `ANTHROPIC_BASE_URL` | `lib/claude-loopback.mjs` |
| OpenCode | Throwaway TS plugin for system text + OpenAI-compatible loopback provider for the actual build-agent request | `lib/opencode-plugin.ts`, `lib/openai-loopback.mjs` |
| Codex | Built-in `codex debug prompt-input` for system text + temporary `CODEX_HOME` custom provider pointed at loopback | `lib/openai-loopback.mjs` |
| pi | Node ESM importing `buildSystemPrompt` and tool definitions from pi's `dist/core/` | `lib/pi-dump.mjs`, `lib/pi-request-dump.mjs` |
| pi | Throwaway `PI_CODING_AGENT_DIR` with a local extension (`lib/pi-loopback-ext.mjs`) that overrides the built-in `anthropic` provider `baseUrl`; pi invoked with `-p hi --approve`; captures Anthropic `/v1/messages` via the same loopback as Claude Code | `lib/pi-dump.mjs` (SYS_* only), `lib/pi-loopback-ext.mjs`, `lib/claude-loopback.mjs` |
| Hermes | Temporary `HERMES_HOME` dir with `config.yaml` pointing at loopback; one-shot via `hermes chat -q "hi"`; OpenAI chat/completions format (non-streaming) | `lib/openai-loopback.mjs` |
| Cursor | Detect install only; no CLI ships on macOS today | none |

Expand All @@ -74,8 +74,8 @@ The `--breakdown` view is a post-processing pass over those captures, not a sepa
- Claude Code's `FIRST_*` includes the base system prompt, built-in tools, MCP tool definitions, resolved `~/.claude/CLAUDE.md` chain, skills catalogue, MCP server instructions, reminders, and the tiny audit user text.
- OpenCode's `FIRST_*` comes from a loopback OpenAI-compatible provider and skips the hidden title-generator request so it captures the build-agent request. Its `SYS_*` comes from the plugin system-transform hook.
- Codex's `FIRST_*` comes from a temporary custom provider in `CODEX_HOME` pointed at the loopback server. Its `SYS_*` still uses `codex debug prompt-input` for the local developer payload.
- pi has no provider loopback in the audit yet; `FIRST_*` is rendered locally from pi's system prompt plus the active tool definitions, so it is comparable for prompt/tool overhead but not a wire capture.
- pi walks ancestor directories for `AGENTS.md`/`CLAUDE.md` so its size scales with cwd. The throwaway cwd gives the baseline; rerun from a real project root to compare. The local render (`pi-request-dump.mjs`) calls `buildSystemPrompt` directly and does NOT go through pi's extension/plugin stack, so skills injected via extensions (the `<available_skills>` block visible in live sessions) are absent from the local render. The skills row for pi will therefore always be blank even when skills are configured.
- pi `FIRST_*` is a real wire capture via Anthropic `/v1/messages` loopback. The throwaway `PI_CODING_AGENT_DIR` contains only the loopback extension and a dummy auth key; skills appear if pi's globally-installed packages inject them. The `SYS_*` column still uses `pi-dump.mjs` (local render, no network).
- pi walks ancestor directories for `AGENTS.md`/`CLAUDE.md` so its size scales with cwd. The throwaway cwd gives the baseline (no project context); rerun from a real project root to compare.
- OpenCode sends tools in OpenAI format (`{type: "function", function: {name, description, parameters}}`). The categorizer reads `t.function.name` (not `t.name`) to detect the `mcpproxy_*` prefix and separate those tools into the MCP tool definitions row. Non-mcpproxy tools go into Tool schemas.
- Hermes: uses `HERMES_HOME` env var to isolate to a temp dir so the real `~/.hermes/config.yaml` is never touched. Hermes injects AGENTS.md/CLAUDE.md from the cwd as "Project Context" — the throwaway cwd gives the baseline (no project context). The version is read from Python package metadata (`importlib.metadata`). Hermes sends non-streaming OpenAI chat/completions requests; the loopback detects `stream: undefined` and returns a plain JSON response (not SSE).

Expand Down
Loading