windowctl
npm install -g @muthuishere/windowctl
windowctl move --app "Google Chrome" --zone 1A # left halfwindowctl move --app "Slack" --monitor 2 --zone 2B # top-right of the second displayThat is the whole product surface for the common case. No config file, no daemon to keep running, no menu-bar app. A window manager you can call from a shell script, a Makefile, a Go program, or an AI agent.
- Platforms
- 3macOS · Windows · Linux, one codebase
- Install
- 1 binaryno runtime, no daemon, no postinstall
- Screenshot
- 110 ms1920×1080 via ScreenCaptureKit (spike S5)
- Stream round-trip
- 0.047 msp50, 10 ms audio frame on loopback (spike S0)
Zones are the whole idea
Section titled “Zones are the whole idea”A zone is a name for a rectangle. You never compute pixels, and you never care which monitor is which resolution.
Omit --monitor and the window stays on the display it is already on — resolved
by majority overlap, not by guessing from its top-left corner. Pass --monitor 2
and it moves there and snaps. Monitors are 1-indexed by ascending (X, Y), so
the leftmost display is always 1, and the numbering survives a reboot or a
re-plug.
One call, one layout
Section titled “One call, one layout”windowctl batch takes a JSON array and applies the whole arrangement — the
thing you actually want when you sit down to work.
cat work-mode.json | windowctl batch --json[ { "app": "Google Chrome", "monitor": 2, "zone": "2B" }, { "app": "Code", "monitor": 1, "zone": "1A" }, { "title": "Terminal", "monitor": 1, "zone": "1B" }, { "app": "Slack", "monitor": 2, "zone": "2C" }]Every entry reports its own result, so a layout that half-applies tells you exactly which window resisted — including when the OS clamps a move rather than honouring it:
windowctl: requested 512x640 at (3840,30), OS clamped to 576x615 at (3840,55) (likely a minimum-window-size constraint)It reads the desktop, not just writes to it
Section titled “It reads the desktop, not just writes to it”$ windowctl windows list --app "Google Chrome" --json[ { "ID": 24815, "App": "Google Chrome", "Title": "windowctl — GitHub", "Bounds": { "X": 1920, "Y": 25, "W": 1920, "H": 1055 }, "Monitor": 2, "Focused": true }]Monitor and Focused are stamped on the unfiltered list before your
filter runs — so they mean the same thing no matter what you filtered out.
$ windowctl monitors listID NAME BOUNDS PRIMARY ACTIVE1 Built-in Display 0,0 1920x1200 true false2 DELL U2723QE 1920,0 3840x2160 false trueACTIVE is the monitor the cursor is on. Map “the external”, “the one I’m
looking at”, “where my cursor is” to an integer, then pass --monitor.
import windowctl "github.com/muthuishere/windowctl"
ws, err := windowctl.ListWindows(windowctl.Filter{App: "Google Chrome"})err = windowctl.MoveZone(windowctl.Target{App: "Google Chrome"}, 2, "2B")The CLI is a thin shell over the library — same behaviour, no shelling out to a binary you then have to ship.
Plain English, if you’d rather
Section titled “Plain English, if you’d rather”The agent skill ships inside the binary. No package to find, no repo to clone, no network:
windowctl install skills“split chrome left, slack right” · “send vscode to my external” · “put everything back to work mode”
It writes window-ctl-skill into ~/.claude/skills/ (and ~/.agents/skills/
when Codex is installed). The skill is the routing layer; every action is still
one windowctl call you could have typed yourself.
There is a second half: the device layer
Section titled “There is a second half: the device layer”The same binary is a headless robot body for testing apps that use the mic, camera, screen and keyboard — over a loopback WebSocket with a token and a per-stream ticket.
windowctl type "hello" windowctl screenshot --out shot.pngwindowctl key cmd+shift+t windowctl click --x 400 --y 300windowctl serve --port 9099 windowctl start video-out --port 9100This half is newer, and the honest state of it is:
| Surface | State | Detail |
|---|---|---|
| Keyboard, mouse, chords, drag | working | CGEvent injection, verified round-trip |
| Screenshot + screen capture | working | ScreenCaptureKit; 1920×1080 in 110 ms |
| Audio device list / set default | working | CoreAudio; reversible, restores the prior default |
| Speaker loopback (audio-out) | frames flow | Process tap delivers samples; non-silent audio needs the system-audio permission |
| Virtual microphone (audio-in) | driver built | Our own CoreAudio plugin; loading it needs a notarized Developer ID build |
| Virtual camera (video-in) | not built | CoreMediaIO extension, designed in ADR 0006 |
| Device layer on Windows | not built | Window control is fully supported there; the device layer is macOS-only so far |
Every row is backed by a runnable spike in the repo and an ADR saying why the decision went that way — including the ones that came back negative.
Why it is built this way
Section titled “Why it is built this way”- A tiny adapter surface. Six primitives per OS (
ListWindows,ListMonitors,Move,Focus, and two permission calls). Everything that can be composed from those lives in the portable layer, so zone maths, monitor resolution, and filter semantics are identical on every platform and unit tested once. - CGO where it counts, nowhere else. macOS talks to CoreGraphics and the
Accessibility API directly — no AppleScript, no helper app, no private SPI.
Windows is
user32.dll. - Permissions are explicit. On macOS the Accessibility prompt fires from
windowctl permissionsor lazily from a move — never fromwindows list. A denied permission is a descriptive error, never a silent no-op. - Specs before code. Every capability has an ADR, a runnable spike that proved it on real hardware, and an OpenSpec slice — then an implementation.
MIT licensed. Start here →