Compare commits

..

7 Commits

Author SHA1 Message Date
David Ibia 6fb7007ea9 feat(omo): coloured tab markers and clickable Ghostty banners
omo-notify now drives three signals from omo's UserPromptSubmit, Stop
and Notification hooks:

- A sound through afplay, so Focus modes cannot mute it
- A Ghostty notification (OSC 777) written to the session's tmux client
  terminal, so clicking it opens the tab it came from. This replaces the
  osascript banner, which opened Script Editor's file picker on click
- omo-status markers in front of the tab title: red error, amber
  waiting for input, green finished, blue working. A tab shows its most
  urgent agent; focusing it clears red, amber and green and selects the
  pane that sent the banner

tmux puts @omo_status in the title and runs omo-status on client focus.
2026-10-06 23:02:55 +01:00
David Ibia d1aeda397a feat(setup): rewrite setup-mac.sh around Brewfiles with idempotent steps
The old script stopped on a syntax error (unclosed if blocks) and had
drifted from the machine: it installed Anaconda, AeroSpace and Oh My
Posh, and linked nothing.

- Eight named steps (xcode homebrew brew links shell runtimes ai apps)
  that check what is in place first, run on their own by name, and
  preview with --dry-run; a failed step is reported while the rest run
- Brewfile holds the CLI tools, fonts and terminals, Brewfile.apps the
  GUI casks and App Store apps; installs never upgrade existing packages
  and skip apps already installed outside Homebrew
- links symlinks the configs and .local/bin scripts, backing up what it
  replaces, and fetches the nvim submodule only on a fresh clone
- Guard .zshrc's ~/.deno/env source for machines without it
- Document the steps in the README
2026-10-06 23:02:43 +01:00
David Ibia 66a3b9ee4d fix(zellij): restore the missing config.kdl
~/.config/zellij/config.kdl linked to a file that was never committed,
so the link was dangling. Restore it from the local backup taken before
the link was made.
2026-10-06 23:02:42 +01:00
David Ibia 90ab5452b1 chore(omo): record the opencode routing notice migration 2026-10-06 23:02:42 +01:00
David Ibia 164dffc5eb feat(tmux): enable extended keys so TUIs receive modified keys
omo and nvim need Shift+Enter and other modified keys, which tmux
otherwise reports as plain Enter. Use CSI-u extended keys, as omo's
tmux guide recommends for tmux 3.5 and newer.
2026-10-06 23:02:29 +01:00
David Ibia 81e5c6be67 feat(owt): add all and cd commands for workspaces across repos
- owt all lists the workspaces of every repo with their branch and
  source checkout, from any directory
- owt cd jumps into a workspace by name, repo/name or an fzf picker
  that starts filtered to the current repo
- An owt function in .zshrc performs the cd, since a script cannot
  change its parent shell's directory
2026-10-06 23:02:29 +01:00
David Ibia d0c2895797 feat(omo): tune reasoning effort and route cheap lanes to z.ai Lite
- Raise main Opus 5.5 to xhigh and its Sol fallback to high
- Raise deep-low Sonnet 5.5 and Sol to high for coding work
- Route quick, explore and librarian to GLM-5.3-Flash low with Sonnet low fallback
- Replace unsupported GLM medium thinking with high in unspecified-low and writing
- Remove the unused [opencode] block
2026-10-05 19:47:25 +01:00
12 changed files with 1493 additions and 386 deletions
+7
View File
@@ -1,5 +1,12 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "~/.local/bin/omo-notify", "timeout": 10 }
]
}
],
"Stop": [
{
"hooks": [
+27 -114
View File
@@ -1,117 +1,14 @@
// OMO routing for OpenCode and the native agent, using Claude, OpenAI and z.ai models.
// OMO routing for the native agent, using Claude, OpenAI and z.ai models.
{
// Shared metadata.
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json",
"_migrations": [
"2026-07-codex-config-jsonc",
"2026-08-reasoning-unification",
"2026-07-opencode-config-unification"
"2026-07-opencode-config-unification",
"2026-09-opencode-routing-notice"
],
// OpenCode agent and category routing.
"[opencode]": {
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json",
"claude_code": {
"plugins_override": {
"oh-my-claudecode@omc": false
}
},
"agents": {
"sisyphus": {
"model": "openai/gpt-6-astra"
},
"hephaestus": {
"model": "openai/gpt-5.6-sol",
"reasoning": "medium"
},
"oracle": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"librarian": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
},
"explore": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
},
"prometheus": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"metis": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"momus": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"atlas": {
"model": "openai/gpt-6-astra"
},
"sisyphus-junior": {
"model": "openai/gpt-6-astra",
"reasoning": "high"
},
"document-writer": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
}
},
"categories": {
"visual-engineering": {
"model": "openai/gpt-6-astra",
"reasoning": "high",
"prompt_append": "For substantive UI work, load frontend-workflow on demand before editing. Use the existing GPT model for design, browser screenshot inspection, and a separate critique pass. Obtain the project design context, supplied references, primary user action, acceptance criteria, run command, assigned files and phase from the delegator. Own one sequential write lane; review-only passes do not edit. Load impeccable for direction and web-design-guidelines for accessibility/interaction review as needed. Preserve established conventions unless redesign is requested; the user brief overrides aesthetic bans. Read screenshot image content directly with the current GPT model, not only paths/DOM and not look_at as a substitute. Render desktop/mobile and intermediate width when warranted, rank up to three material visible issues, fix them, recapture, and retest interactions/states. Normally at most three passes; stop when acceptance criteria pass and report residual issues. Do not delegate a plan for a small implementation whose brief already resolves the decisions."
},
"ultrabrain": {
"model": "openai/gpt-6-astra",
"reasoning": "max"
},
"deep-low": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"artistry": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"quick": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
},
"unspecified-low": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
},
"unspecified-high": {
"model": "zai-coding-plan/glm-5.3",
"reasoning": "high"
},
"writing": {
"model": "zai-coding-plan/glm-5.3-flash",
"reasoning": "low"
}
},
"sisyphus_agent": {
"planner_enabled": true,
"replace_plan": true
},
"telemetry": false,
"model_fallback": true,
"disabled_hooks": [
"auto-update-checker",
"startup-toast"
]
},
// Native agent and category routing.
"[native]": {
// Claude leads implementation; GPT independently reviews technical work.
@@ -122,11 +19,11 @@
"models": [
{
"model": "anthropic-subscription/claude-opus-5-5",
"reasoning": "medium"
"reasoning": "xhigh"
},
{
"model": "chatgpt-subscription/gpt-6.1-sol",
"reasoning": "medium"
"reasoning": "high"
}
]
}
@@ -160,11 +57,11 @@
"models": [
{
"model": "anthropic-subscription/claude-sonnet-5-5",
"reasoning": "medium"
"reasoning": "high"
},
{
"model": "chatgpt-subscription/gpt-6.1-sol",
"reasoning": "medium"
"reasoning": "high"
},
{
"model": "zai/glm-5.3",
@@ -179,11 +76,11 @@
"quick": {
"models": [
{
"model": "anthropic-subscription/claude-sonnet-5-5",
"model": "zai/glm-5.3-flash",
"reasoning": "low"
},
{
"model": "zai/glm-5.3-flash",
"model": "anthropic-subscription/claude-sonnet-5-5",
"reasoning": "low"
}
]
@@ -204,7 +101,7 @@
},
{
"model": "zai/glm-5.3",
"reasoning": "medium"
"reasoning": "high"
}
]
},
@@ -221,20 +118,36 @@
},
{
"model": "zai/glm-5.3",
"reasoning": "medium"
"reasoning": "high"
}
]
}
},
"agents": {
"explore": {
"models": [
{
"model": "zai/glm-5.3-flash",
"reasoning": "low"
},
{
"model": "anthropic-subscription/claude-sonnet-5-5",
"reasoning": "low"
}
]
},
"librarian": {
"models": [
{
"model": "zai/glm-5.3-flash",
"reasoning": "low"
},
{
"model": "anthropic-subscription/claude-sonnet-5-5",
"reasoning": "low"
}
]
},
"plan-consultant": {
"models": [
{
+452
View File
@@ -0,0 +1,452 @@
keybinds clear-defaults=true {
locked {
bind "Ctrl g" { SwitchToMode "normal"; }
}
pane {
bind "left" { MoveFocus "left"; }
bind "down" { MoveFocus "down"; }
bind "up" { MoveFocus "up"; }
bind "right" { MoveFocus "right"; }
bind "c" { SwitchToMode "renamepane"; PaneNameInput 0; }
bind "d" { NewPane "down"; SwitchToMode "normal"; }
bind "e" { TogglePaneEmbedOrFloating; SwitchToMode "normal"; }
bind "f" { ToggleFocusFullscreen; SwitchToMode "normal"; }
bind "h" { MoveFocus "left"; }
bind "i" { TogglePanePinned; SwitchToMode "normal"; }
bind "j" { MoveFocus "down"; }
bind "k" { MoveFocus "up"; }
bind "l" { MoveFocus "right"; }
bind "n" { NewPane; SwitchToMode "normal"; }
bind "p" { SwitchFocus; }
bind "Ctrl p" { SwitchToMode "normal"; }
bind "r" { NewPane "right"; SwitchToMode "normal"; }
bind "w" { ToggleFloatingPanes; SwitchToMode "normal"; }
bind "z" { TogglePaneFrames; SwitchToMode "normal"; }
}
tab {
bind "left" { GoToPreviousTab; }
bind "down" { GoToNextTab; }
bind "up" { GoToPreviousTab; }
bind "right" { GoToNextTab; }
bind "1" { GoToTab 1; SwitchToMode "normal"; }
bind "2" { GoToTab 2; SwitchToMode "normal"; }
bind "3" { GoToTab 3; SwitchToMode "normal"; }
bind "4" { GoToTab 4; SwitchToMode "normal"; }
bind "5" { GoToTab 5; SwitchToMode "normal"; }
bind "6" { GoToTab 6; SwitchToMode "normal"; }
bind "7" { GoToTab 7; SwitchToMode "normal"; }
bind "8" { GoToTab 8; SwitchToMode "normal"; }
bind "9" { GoToTab 9; SwitchToMode "normal"; }
bind "[" { BreakPaneLeft; SwitchToMode "normal"; }
bind "]" { BreakPaneRight; SwitchToMode "normal"; }
bind "b" { BreakPane; SwitchToMode "normal"; }
bind "h" { GoToPreviousTab; }
bind "j" { GoToNextTab; }
bind "k" { GoToPreviousTab; }
bind "l" { GoToNextTab; }
bind "n" { NewTab; SwitchToMode "normal"; }
bind "r" { SwitchToMode "renametab"; TabNameInput 0; }
bind "s" { ToggleActiveSyncTab; SwitchToMode "normal"; }
bind "Ctrl t" { SwitchToMode "normal"; }
bind "x" { CloseTab; SwitchToMode "normal"; }
bind "tab" { ToggleTab; }
}
resize {
bind "left" { Resize "Increase left"; }
bind "down" { Resize "Increase down"; }
bind "up" { Resize "Increase up"; }
bind "right" { Resize "Increase right"; }
bind "+" { Resize "Increase"; }
bind "-" { Resize "Decrease"; }
bind "=" { Resize "Increase"; }
bind "H" { Resize "Decrease left"; }
bind "J" { Resize "Decrease down"; }
bind "K" { Resize "Decrease up"; }
bind "L" { Resize "Decrease right"; }
bind "h" { Resize "Increase left"; }
bind "j" { Resize "Increase down"; }
bind "k" { Resize "Increase up"; }
bind "l" { Resize "Increase right"; }
bind "Ctrl n" { SwitchToMode "normal"; }
}
move {
bind "left" { MovePane "left"; }
bind "down" { MovePane "down"; }
bind "up" { MovePane "up"; }
bind "right" { MovePane "right"; }
bind "h" { MovePane "left"; }
bind "Ctrl h" { SwitchToMode "normal"; }
bind "j" { MovePane "down"; }
bind "k" { MovePane "up"; }
bind "l" { MovePane "right"; }
bind "n" { MovePane; }
bind "p" { MovePaneBackwards; }
bind "tab" { MovePane; }
}
scroll {
bind "e" { EditScrollback; SwitchToMode "normal"; }
bind "s" { SwitchToMode "entersearch"; SearchInput 0; }
}
search {
bind "c" { SearchToggleOption "CaseSensitivity"; }
bind "n" { Search "down"; }
bind "o" { SearchToggleOption "WholeWord"; }
bind "p" { Search "up"; }
bind "w" { SearchToggleOption "Wrap"; }
}
session {
bind "a" {
LaunchOrFocusPlugin "zellij:about" {
floating true
move_to_focused_tab true
}
SwitchToMode "normal"
}
bind "c" {
LaunchOrFocusPlugin "configuration" {
floating true
move_to_focused_tab true
}
SwitchToMode "normal"
}
bind "Ctrl o" { SwitchToMode "normal"; }
bind "p" {
LaunchOrFocusPlugin "plugin-manager" {
floating true
move_to_focused_tab true
}
SwitchToMode "normal"
}
bind "w" {
LaunchOrFocusPlugin "session-manager" {
floating true
move_to_focused_tab true
}
SwitchToMode "normal"
}
}
shared_except "locked" {
bind "Alt left" { MoveFocusOrTab "left"; }
bind "Alt down" { MoveFocus "down"; }
bind "Alt up" { MoveFocus "up"; }
bind "Alt right" { MoveFocusOrTab "right"; }
bind "Alt +" { Resize "Increase"; }
bind "Alt -" { Resize "Decrease"; }
bind "Alt =" { Resize "Increase"; }
bind "Alt [" { PreviousSwapLayout; }
bind "Alt ]" { NextSwapLayout; }
bind "Alt f" { ToggleFloatingPanes; }
bind "Ctrl g" { SwitchToMode "locked"; }
bind "Alt h" { MoveFocusOrTab "left"; }
bind "Alt i" { MoveTab "left"; }
bind "Alt j" { MoveFocus "down"; }
bind "Alt k" { MoveFocus "up"; }
bind "Alt l" { MoveFocusOrTab "right"; }
bind "Alt n" { NewPane; }
bind "Alt o" { MoveTab "right"; }
bind "Ctrl q" { Quit; }
}
shared_except "locked" "move" {
bind "Ctrl h" { SwitchToMode "move"; }
}
shared_except "locked" "session" {
bind "Ctrl o" { SwitchToMode "session"; }
}
shared_except "locked" "scroll" "search" "tmux" {
bind "Ctrl b" { SwitchToMode "tmux"; }
}
shared_except "locked" "scroll" "search" {
bind "Ctrl s" { SwitchToMode "scroll"; }
}
shared_except "locked" "tab" {
bind "Ctrl t" { SwitchToMode "tab"; }
}
shared_except "locked" "pane" {
bind "Ctrl p" { SwitchToMode "pane"; }
}
shared_except "locked" "resize" {
bind "Ctrl n" { SwitchToMode "resize"; }
}
shared_except "normal" "locked" "entersearch" {
bind "enter" { SwitchToMode "normal"; }
}
shared_except "normal" "locked" "entersearch" "renametab" "renamepane" {
bind "esc" { SwitchToMode "normal"; }
}
shared_among "pane" "tmux" {
bind "x" { CloseFocus; SwitchToMode "normal"; }
}
shared_among "scroll" "search" {
bind "PageDown" { PageScrollDown; }
bind "PageUp" { PageScrollUp; }
bind "left" { PageScrollUp; }
bind "down" { ScrollDown; }
bind "up" { ScrollUp; }
bind "right" { PageScrollDown; }
bind "Ctrl b" { PageScrollUp; }
bind "Ctrl c" { ScrollToBottom; SwitchToMode "normal"; }
bind "d" { HalfPageScrollDown; }
bind "Ctrl f" { PageScrollDown; }
bind "h" { PageScrollUp; }
bind "j" { ScrollDown; }
bind "k" { ScrollUp; }
bind "l" { PageScrollDown; }
bind "Ctrl s" { SwitchToMode "normal"; }
bind "u" { HalfPageScrollUp; }
}
entersearch {
bind "Ctrl c" { SwitchToMode "scroll"; }
bind "esc" { SwitchToMode "scroll"; }
bind "enter" { SwitchToMode "search"; }
}
renametab {
bind "esc" { UndoRenameTab; SwitchToMode "tab"; }
}
shared_among "renametab" "renamepane" {
bind "Ctrl c" { SwitchToMode "normal"; }
}
renamepane {
bind "esc" { UndoRenamePane; SwitchToMode "pane"; }
}
shared_among "session" "tmux" {
bind "d" { Detach; }
}
tmux {
bind "left" { MoveFocus "left"; SwitchToMode "normal"; }
bind "down" { MoveFocus "down"; SwitchToMode "normal"; }
bind "up" { MoveFocus "up"; SwitchToMode "normal"; }
bind "right" { MoveFocus "right"; SwitchToMode "normal"; }
bind "space" { NextSwapLayout; }
bind "\"" { NewPane "down"; SwitchToMode "normal"; }
bind "%" { NewPane "right"; SwitchToMode "normal"; }
bind "," { SwitchToMode "renametab"; }
bind "[" { SwitchToMode "scroll"; }
bind "Ctrl b" { Write 2; SwitchToMode "normal"; }
bind "c" { NewTab; SwitchToMode "normal"; }
bind "h" { MoveFocus "left"; SwitchToMode "normal"; }
bind "j" { MoveFocus "down"; SwitchToMode "normal"; }
bind "k" { MoveFocus "up"; SwitchToMode "normal"; }
bind "l" { MoveFocus "right"; SwitchToMode "normal"; }
bind "n" { GoToNextTab; SwitchToMode "normal"; }
bind "o" { FocusNextPane; }
bind "p" { GoToPreviousTab; SwitchToMode "normal"; }
bind "z" { ToggleFocusFullscreen; SwitchToMode "normal"; }
}
}
// Plugin aliases - can be used to change the implementation of Zellij
// changing these requires a restart to take effect
plugins {
about location="zellij:about"
compact-bar location="zellij:compact-bar"
configuration location="zellij:configuration"
filepicker location="zellij:strider" {
cwd "/"
}
plugin-manager location="zellij:plugin-manager"
session-manager location="zellij:session-manager"
status-bar location="zellij:status-bar"
strider location="zellij:strider"
tab-bar location="zellij:tab-bar"
welcome-screen location="zellij:session-manager" {
welcome_screen true
}
}
// Plugins to load in the background when a new session starts
// eg. "file:/path/to/my-plugin.wasm"
// eg. "https://example.com/my-plugin.wasm"
load_plugins {
}
// Use a simplified UI without special fonts (arrow glyphs)
// Options:
// - true
// - false (Default)
//
// simplified_ui true
// Choose the theme that is specified in the themes section.
// Default: default
//
// theme "dracula"
// Choose the base input mode of zellij.
// Default: normal
//
// default_mode "locked"
// Choose the path to the default shell that zellij will use for opening new panes
// Default: $SHELL
//
// default_shell "fish"
// Choose the path to override cwd that zellij will use for opening new panes
//
// default_cwd "/tmp"
// The name of the default layout to load on startup
// Default: "default"
//
// default_layout "compact"
// The folder in which Zellij will look for layouts
// (Requires restart)
//
// layout_dir "/tmp"
// The folder in which Zellij will look for themes
// (Requires restart)
//
// theme_dir "/tmp"
// Toggle enabling the mouse mode.
// On certain configurations, or terminals this could
// potentially interfere with copying text.
// Options:
// - true (default)
// - false
//
// mouse_mode false
// Toggle having pane frames around the panes
// Options:
// - true (default, enabled)
// - false
//
// pane_frames false
// When attaching to an existing session with other users,
// should the session be mirrored (true)
// or should each user have their own cursor (false)
// (Requires restart)
// Default: false
//
// mirror_session true
// Choose what to do when zellij receives SIGTERM, SIGINT, SIGQUIT or SIGHUP
// eg. when terminal window with an active zellij session is closed
// (Requires restart)
// Options:
// - detach (Default)
// - quit
//
// on_force_close "quit"
// Configure the scroll back buffer size
// This is the number of lines zellij stores for each pane in the scroll back
// buffer. Excess number of lines are discarded in a FIFO fashion.
// (Requires restart)
// Valid values: positive integers
// Default value: 10000
//
// scroll_buffer_size 10000
// Provide a command to execute when copying text. The text will be piped to
// the stdin of the program to perform the copy. This can be used with
// terminal emulators which do not support the OSC 52 ANSI control sequence
// that will be used by default if this option is not set.
// Examples:
//
// copy_command "xclip -selection clipboard" // x11
// copy_command "wl-copy" // wayland
// copy_command "pbcopy" // osx
//
// copy_command "pbcopy"
// Choose the destination for copied text
// Allows using the primary selection buffer (on x11/wayland) instead of the system clipboard.
// Does not apply when using copy_command.
// Options:
// - system (default)
// - primary
//
// copy_clipboard "primary"
// Enable automatic copying (and clearing) of selection when releasing mouse
// Default: true
//
// copy_on_select true
// Path to the default editor to use to edit pane scrollbuffer
// Default: $EDITOR or $VISUAL
// scrollback_editor "/usr/bin/vim"
// A fixed name to always give the Zellij session.
// Consider also setting `attach_to_session true,`
// otherwise this will error if such a session exists.
// Default: <RANDOM>
//
// session_name "My singleton session"
// When `session_name` is provided, attaches to that session
// if it is already running or creates it otherwise.
// Default: false
//
// attach_to_session true
// Toggle between having Zellij lay out panes according to a predefined set of layouts whenever possible
// Options:
// - true (default)
// - false
//
// auto_layout false
// Whether sessions should be serialized to the cache folder (including their tabs/panes, cwds and running commands) so that they can later be resurrected
// Options:
// - true (default)
// - false
//
// session_serialization false
// Whether pane viewports are serialized along with the session, default is false
// Options:
// - true
// - false (default)
//
// serialize_pane_viewport false
// Scrollback lines to serialize along with the pane viewport when serializing sessions, 0
// defaults to the scrollback size. If this number is higher than the scrollback size, it will
// also default to the scrollback size. This does nothing if `serialize_pane_viewport` is not true.
//
// scrollback_lines_to_serialize 10000
// Enable or disable the rendering of styled and colored underlines (undercurl).
// May need to be disabled for certain unsupported terminals
// (Requires restart)
// Default: true
//
// styled_underlines false
// How often in seconds sessions are serialized
//
// serialization_interval 10000
// Enable or disable writing of session metadata to disk (if disabled, other sessions might not know
// metadata info on this session)
// (Requires restart)
// Default: false
//
// disable_session_metadata false
// Enable or disable support for the enhanced Kitty Keyboard Protocol (the host terminal must also support it)
// (Requires restart)
// Default: true (if the host terminal supports it)
//
// support_kitty_keyboard_protocol false
// Whether to stack panes when resizing beyond a certain size
// Default: true
//
// stacked_resize false
// Whether to show tips on startup
// Default: true
//
// show_startup_tips false
// Whether to show release notes on first version run
// Default: true
//
// show_release_notes false
+67 -25
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bash
# macOS notification for omo (senpi) Stop and Notification hooks.
# Reads the hook JSON payload on stdin. Wired up in ~/.omo/agent/hooks.json.
# Ding, tab marker and notification for omo (senpi) hooks: UserPromptSubmit,
# Stop and Notification. Reads the hook JSON payload on stdin. Wired up in
# ~/.omo/agent/hooks.json. The sound plays through afplay, so Focus modes and
# notification permissions cannot mute it; the banner is a silent extra.
# Ghostty posts the banner, so clicking it opens the tab it came from, and
# omo-status then selects the session's pane. omo-status also colours that tab:
# blue working, amber waiting for you, green finished, red error.
# Hooks run with a minimal environment, so make Homebrew tools reachable.
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
@@ -11,42 +16,79 @@ field() { jq -r "$1 // empty" <<<"$payload" 2>/dev/null; }
event=$(field '.hook_event_name')
project=$(basename "$(field '.cwd')")
# Only top-level sessions live under .omo/agent/sessions/. Subagents, team members
# and other background sessions keep transcripts elsewhere (e.g. senpi-task/children).
transcript=$(field '.transcript_path')
[[ -z "$transcript" || "$transcript" == */.omo/agent/sessions/* ]] || exit 0
message=""
sound=""
case "$event" in
UserPromptSubmit)
state=working
;;
Stop)
case "$(field '.stopReason')" in
# Esc/interrupts are your own doing; no need to be told about them.
[[ "$(field '.stopReason')" == "aborted" ]] && exit 0
# Only top-level sessions live under .omo/agent/sessions/. Subagents, team members
# and other background sessions keep transcripts elsewhere (e.g. senpi-task/children).
transcript=$(field '.transcript_path')
[[ -z "$transcript" || "$transcript" == */.omo/agent/sessions/* ]] || exit 0
if [[ "$(field '.stopReason')" == "error" ]]; then
message="Stopped with an error"
else
message="Task finished"
fi
sound="Glass"
aborted) state="clear" ;;
error) state="error" message="Stopped with an error" sound="Glass" ;;
*) state="done" message="Task finished" sound="Glass" ;;
esac
;;
Notification)
# Only alert when a question appears, not when it is answered or times out.
[[ "$(field '.kind')" == "ask-user-asked" ]] || exit 0
message="Needs your input: $(field '.title')"
sound="Ping"
if [[ "$(field '.kind')" == "ask-user-asked" ]]; then
state=input message="Needs your input: $(field '.title')" sound="Ping"
else
# Answered or timed out: the agent carries on.
state=working
fi
;;
*)
exit 0
;;
esac
# Background the ding so the hook returns immediately; redirect output so the
# hook runner is not left waiting on afplay's inherited pipes.
[[ -n "$sound" ]] && afplay "/System/Library/Sounds/${sound}.aiff" >/dev/null 2>&1 &
# Find the tmux pane omo runs in: hooks run detached without TMUX_PANE, so walk
# up the process tree to the first ancestor whose tty is a tmux pane.
# Outside tmux, the first tty found is the Ghostty tab's own terminal.
pane=""
own_tty=""
panes=$(tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null)
pid=$PPID
while [[ -z "$pane" && "${pid:-1}" -gt 1 ]]; do
tty=$(ps -o tty= -p "$pid" | tr -d ' ')
if [[ -n "$tty" && "$tty" != "??" ]]; then
[[ -n "$own_tty" ]] || own_tty="/dev/$tty"
[[ -n "$panes" ]] && pane=$(awk -v t="/dev/$tty" '$1 == t { print $2 }' <<<"$panes")
fi
pid=$(ps -o ppid= -p "$pid" | tr -d ' ')
done
[[ -n "$pane" ]] && "$HOME/.local/bin/omo-status" set "$pane" "$state"
[[ -n "$message" ]] || exit 0
log="${OMO_NOTIFY_LOG:-$HOME/.local/state/omo-notify.log}"
mkdir -p "$(dirname "$log")"
printf '%s %s %s %s [%s %s]\n' "$(date '+%F %T')" "$event" "$project" "$message" \
"$(field '.session_id')" "$(field '.transcript_path')" >>"$log"
printf '%s %s %s %s [%s %s] pane=%s\n' "$(date '+%F %T')" "$event" "$project" "$message" \
"$(field '.session_id')" "$(field '.transcript_path')" "${pane:-none}" >>"$log"
# Pass values as argv so quotes in titles cannot break the AppleScript.
osascript - "omo: ${project:-session}" "$message" "$sound" <<'APPLESCRIPT' >/dev/null 2>&1
on run argv
display notification (item 2 of argv) with title (item 1 of argv) sound name (item 3 of argv)
end run
APPLESCRIPT
# The banner is an OSC 777 notification for Ghostty. In tmux it goes to the tab's
# tmux client terminal, because tmux does not pass it on from a pane, and
# @omo_jump tells omo-status which pane to select when that tab gets focus.
target=$own_tty
if [[ -n "$pane" ]]; then
session=$(tmux display-message -p -t "$pane" '#{session_id}')
target=$(tmux list-clients -t "$session" -F '#{client_activity} #{client_tty}' | sort -rn | awk 'NR == 1 { print $2 }')
tmux set-option -t "$session" @omo_jump "$pane"
fi
# Control characters would end the escape sequence early; ";" separates its fields.
clean() { tr -d '\000-\037\177' <<<"$1" | tr ';' ','; }
if [[ -n "$target" && -w "$target" ]]; then
printf '\033]777;notify;%s;%s\a' "$(clean "omo: ${project:-session}")" "$(clean "$message")" >"$target"
fi
exit 0
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Coloured attention markers on Ghostty tabs for omo sessions. tmux puts the
# session's @omo_status in front of the tab title (set-titles-string in
# ~/.tmux.conf), so each tab shows what its agents need:
# 🔴 stopped with an error 🟡 waiting for your answer
# 🟢 finished 🔵 working
# Each pane running omo keeps its own state in the pane option @omo_state, and
# the tab shows the most urgent one. Focusing the tab clears red, amber and green,
# and selects the pane whose banner you were sent (@omo_jump, set by omo-notify).
#
# Usage: omo-status set <pane> <working|input|done|error|clear>
# omo-status focus <client> (run by the tmux client-focus-in hook)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
icon() {
case $1 in
error) echo "🔴" ;;
input) echo "🟡" ;;
done) echo "🟢" ;;
working) echo "🔵" ;;
esac
}
rank() {
case $1 in
error) echo 4 ;;
input) echo 3 ;;
done) echo 2 ;;
working) echo 1 ;;
*) echo 0 ;;
esac
}
show_most_urgent() {
local session=$1 state best=""
while read -r state; do
if (($(rank "$state") > $(rank "$best"))); then best=$state; fi
done < <(tmux list-panes -s -t "$session" -F '#{@omo_state}')
if [[ -n $best ]]; then
tmux set-option -t "$session" @omo_status "$(icon "$best")"
else
tmux set-option -u -t "$session" @omo_status
fi
}
# You are looking at a pane when its tab has focus and its window is showing.
looking_at() {
local pane=$1 session=$2
[[ $(tmux display-message -p -t "$pane" '#{window_active}') == 1 ]] &&
tmux list-clients -t "$session" -F '#{client_flags}' | grep -q focused
}
case ${1:-} in
set)
pane=${2:?pane id required} state=${3:?state required}
session=$(tmux display-message -p -t "$pane" '#{session_id}' 2>/dev/null) || exit 0
if [[ $state != working && $state != clear ]] && looking_at "$pane" "$session"; then
state=clear
fi
if [[ $state == clear ]]; then
tmux set-option -p -u -t "$pane" @omo_state
else
tmux set-option -p -t "$pane" @omo_state "$state"
fi
show_most_urgent "$session"
;;
focus)
client=${2:?client required}
session=$(tmux list-clients -F '#{client_name} #{session_id}' | awk -v c="$client" '$1 == c { print $2 }')
[[ -n $session ]] || exit 0
jump=$(tmux show-option -qv -t "$session" @omo_jump)
if [[ -n $jump ]]; then
tmux set-option -u -t "$session" @omo_jump
tmux select-window -t "$jump" \; select-pane -t "$jump" 2>/dev/null
fi
while read -r pane state; do
case $state in
input | done | error) tmux set-option -p -u -t "$pane" @omo_state ;;
esac
done < <(tmux list-panes -s -t "$session" -F '#{pane_id} #{@omo_state}')
show_most_urgent "$session"
;;
*)
sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2
exit 2
;;
esac
+75
View File
@@ -43,6 +43,12 @@ Usage (run from anywhere inside the repo):
each issue to In Progress, and to In Review with the PR link.
owt open <name> Open a tmux window running the agent in an existing workspace
owt ls List this repo's workspaces and their branches
owt all List the workspaces of every repo with their branches and
source checkouts (works from any directory)
owt cd [name] Jump into a workspace of any repo: <name> or <repo>/<name>,
otherwise pick with fzf (filtered to this repo's workspaces
when run inside one; jumps straight in if only one matches).
Needs the owt shell function in .zshrc; without it, prints the path.
owt path <name> Print a workspace path (cd "$(owt path lagos)")
owt rm <name> [--force]
Remove the workspace and delete its branch if merged.
@@ -291,6 +297,73 @@ cmd_path() {
printf '%s\n' "$dir"
}
# One "<repo>/<name>\t<branch>\t<source checkout>" line per workspace under
# $OWT_ROOT, across every repo. Works outside a repository.
all_workspaces() {
local dir common branch src
for dir in "$OWT_ROOT"/*/*/; do
dir="${dir%/}"
[[ -f "$dir/.git" ]] || continue
if common="$(git -C "$dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"; then
branch="$(git -C "$dir" symbolic-ref --quiet --short HEAD || printf '(detached)')"
src="$(dirname "$common")"
if [[ "$src" == "$HOME"/* ]]; then src='~'/"${src#"$HOME"/}"; fi
else
branch="?"
src="(source checkout missing)"
fi
printf '%s\t%s\t%s\n' "${dir#"$OWT_ROOT"/}" "$branch" "$src"
done
}
cmd_all() {
local rows
rows="$(all_workspaces)"
if [[ -z "$rows" ]]; then
say "no workspaces under $OWT_ROOT"
return 0
fi
{ printf 'WORKSPACE\tBRANCH\tSOURCE\n'; printf '%s\n' "$rows"; } | column -t -s $'\t'
}
# Print a workspace path for the owt shell function in .zshrc to cd into.
cmd_cd() {
[[ $# -le 1 ]] || die "usage: owt cd [name]"
local query="${1:-}" rows repo="" common picked=""
rows="$(all_workspaces)"
[[ -n "$rows" ]] || die "no workspaces under $OWT_ROOT"
if common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"; then
repo="$(basename "$(dirname "$common")")"
fi
# Exact <repo>/<name>, then <name> in this repo, then <name> in exactly one repo.
if [[ -n "$query" ]]; then
picked="$(printf '%s\n' "$rows" | awk -F'\t' -v q="$query" -v r="$repo" '
{ ws = $1; name = substr(ws, index(ws, "/") + 1) }
ws == q || ws == r "/" q { print ws; found = 1; exit }
name == q { hits[++n] = ws }
END { if (!found && n == 1) print hits[1] }')"
fi
if [[ -z "$picked" ]]; then
command -v fzf >/dev/null || die "no workspace named '$query'; install fzf to pick one, or see 'owt all'"
if [[ -z "$query" && -n "$repo" && $'\n'"$rows" == *$'\n'"$repo/"* ]]; then query="$repo/"; fi
picked="$(paste <(cut -f1 <<<"$rows") <(column -t -s $'\t' <<<"$rows") \
| fzf --delimiter=$'\t' --with-nth=2.. --query="$query" --select-1 --exit-0 \
--height=40% --reverse --prompt='owt cd> ' \
| cut -f1)" || true
if [[ -z "$picked" ]]; then
[[ -z "$query" ]] || die "no workspace matches '$query'"
die "no workspace selected"
fi
fi
if [[ -t 1 ]]; then
say "printing the path only; the owt shell function in .zshrc does the cd"
fi
printf '%s/%s\n' "$OWT_ROOT" "$picked"
}
cmd_rm() {
local name="" force=0
while [[ $# -gt 0 ]]; do
@@ -432,6 +505,8 @@ case "${1:-}" in
done) shift; cmd_done "$@" ;;
open) shift; cmd_open "$@" ;;
ls) shift; cmd_ls ;;
all) shift; cmd_all ;;
cd) shift; cmd_cd "$@" ;;
path) shift; cmd_path "$@" ;;
rm) shift; cmd_rm "$@" ;;
""|-h|--help|help) usage ;;
+8 -1
View File
@@ -1,6 +1,10 @@
set -g mouse on
bind r source-file ~/.tmux.conf
set-option -a terminal-features 'xterm*:RGB'
# Extended keys so TUIs (omo, nvim) receive Shift+Enter and other modified keys
set -g extended-keys on
set -g extended-keys-format csi-u
set-option -a terminal-features 'xterm*:extkeys'
set-option -sg escape-time 10
set-option -g focus-events on
set-window-option -g mode-keys vi
@@ -60,7 +64,10 @@ bind -r m resize-pane -Z
# Push tmux window/session info to the terminal title
set -g set-titles on
set -g set-titles-string "#{session_name}"
# omo-status puts a coloured marker (@omo_status) in front of the session name;
# focusing the tab clears the attention markers.
set -g set-titles-string "#{?@omo_status,#{@omo_status} ,}#{session_name}"
set-hook -g client-focus-in 'run-shell -b "~/.local/bin/omo-status focus #{q:hook_client}"'
# Set plugin manager path before loading plugins
set-environment -g TMUX_PLUGIN_MANAGER_PATH "$HOME/.tmux/plugins"
+12 -1
View File
@@ -322,7 +322,7 @@ if [ -d "$HOME/neovim/bin" ]; then
export PATH="$HOME/neovim/bin:$PATH"
fi
. "$HOME/.deno/env"
[ -f "$HOME/.deno/env" ] && . "$HOME/.deno/env"
export NVM_DIR="$HOME/.config/nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
@@ -357,6 +357,17 @@ sshm() { TERM=xterm-256color command sshm "$@"; }
source <(sshm completion zsh)
# `owt cd` jumps into an owt workspace. A script cannot change this shell's
# directory, so owt prints the path and this wrapper does the cd.
owt() {
if [[ "$1" == cd ]]; then
local dir
dir="$(command owt "$@")" && cd "$dir"
else
command owt "$@"
fi
}
export SSH_AUTH_SOCK=~/Library/Group\ Containers/2BUA8C4S2C.com.1password/t/agent.sock
# Connect to flowstate-remote over mosh instead of ssh. Mosh is UDP with no TCP
+172
View File
@@ -0,0 +1,172 @@
# Command-line tools, fonts and terminals. Installed by `./setup-mac.sh brew`.
# GUI apps and App Store apps live in Brewfile.apps.
# To see what is installed but missing here: brew bundle dump --file=- | diff Brewfile -
tap "bufbuild/buf"
tap "gu1llaum-3/sshm"
tap "hashicorp/tap"
tap "leoafarias/fvm"
tap "mongodb/brew"
tap "oven-sh/bun"
tap "pbkit/tap"
tap "reoclo/tap"
tap "teamookla/speedtest"
# Shell, prompt and terminal tools (.zshrc and .tmux.conf rely on these)
brew "zsh-syntax-highlighting"
brew "starship"
brew "zoxide"
brew "direnv"
brew "fzf"
brew "eza"
brew "bat"
brew "grc"
brew "tmux"
brew "zellij"
brew "yazi"
brew "mosh"
brew "openssh"
brew "gu1llaum-3/sshm/sshm", trusted: true
# Everyday CLI
brew "coreutils"
brew "curl"
brew "wget"
brew "jq"
brew "ripgrep"
brew "fd"
brew "tree"
brew "watch"
brew "htop"
brew "dust"
brew "telnet"
brew "stow"
brew "sevenzip"
brew "gnupg"
brew "mas" # App Store installs in Brewfile.apps
# Git
brew "gh"
brew "tea"
brew "lazygit"
brew "git-filter-repo"
brew "gitleaks"
brew "colordiff"
brew "diffstat"
# Editors and code tools
brew "neovim"
brew "luarocks"
brew "ast-grep"
brew "silicon"
brew "just"
brew "bufbuild/buf/buf", trusted: true
brew "pbkit/tap/pbkit", trusted: true
# Languages and version managers
brew "asdf"
brew "pyenv"
brew "pyenv-virtualenv"
brew "python@3.11"
brew "python@3.12"
brew "ruby", link: false
brew "go"
brew "deno"
brew "oven-sh/bun/bun", trusted: true
brew "pnpm"
brew "uv"
brew "leoafarias/fvm/fvm", trusted: true
# Python tooling
brew "black"
brew "ruff"
brew "mypy"
brew "ipython"
brew "python-lsp-server"
brew "cookiecutter"
# Build toolchain and native libraries
brew "cmake"
brew "gcc"
brew "lld"
brew "libffi"
brew "libraw"
brew "librist"
brew "libssh"
brew "grpc"
brew "portaudio"
brew "tcl-tk"
brew "cryptography"
brew "python-setuptools"
# Containers, CI, cloud and deploy
brew "lazydocker"
brew "dive"
brew "hadolint"
brew "trivy"
brew "nixpacks"
brew "act"
brew "actionlint"
brew "woodpecker-cli"
brew "awscli"
brew "doctl"
brew "cloudflared"
brew "vercel"
brew "pscale"
brew "hashicorp/tap/terraform", trusted: true
brew "bitwarden-cli"
brew "reoclo/tap/reoclo", trusted: true
# Databases, servers and API clients
brew "mongosh"
brew "mongodb/brew/mongodb-community", trusted: true
brew "mysql"
brew "mysql-client@8.4"
brew "redis"
brew "httpd"
brew "posting"
# Media, documents and images
brew "ffmpeg"
brew "mpv"
brew "imagemagick"
brew "graphicsmagick"
brew "tesseract"
brew "ghostscript"
brew "poppler"
brew "weasyprint"
brew "pngquant"
brew "potrace"
brew "chafa"
brew "folderify"
# Apple and mobile builds
brew "xcodegen"
brew "xcbeautify"
brew "create-dmg"
# Hardware, installers and disk images
brew "arduino-cli"
brew "makensis"
brew "wimlib"
brew "cabextract"
brew "cdrtools"
# Networking
brew "tor"
brew "teamookla/speedtest/speedtest", trusted: true
# Fonts, terminals and CLI apps
cask "font-fira-code-nerd-font"
cask "font-hack-nerd-font"
cask "font-symbols-only-nerd-font"
cask "ghostty"
cask "wezterm"
cask "1password-cli"
cask "git-credential-manager"
cask "ngrok"
# Go and uv tools
go "golang.org/x/tools/gopls"
uv "headroom-ai"
uv "vectorcode"
+119
View File
@@ -0,0 +1,119 @@
# GUI apps and App Store apps. Installed by `./setup-mac.sh apps`.
# Sign in to the App Store first, or the mas entries fail.
# Apps already in /Applications that Homebrew did not install are skipped by
# setup-mac.sh, so this is safe to run on a Mac that already has them.
# Install by hand (no usable cask): Bartender 6 (the cask is Bartender 7),
# FileZilla, VMware Fusion and YubiKey Manager.
tap "glinford/tap"
tap "johannesnagl/tap"
# Development
cask "docker-desktop"
cask "zed"
cask "android-studio"
cask "bruno"
cask "beekeeper-studio"
cask "mongodb-compass"
cask "redis-insight"
cask "charles"
cask "cyberduck"
cask "chromium"
cask "johannesnagl/tap/showmd", trusted: true
# AI and agents
cask "conductor"
cask "claude"
cask "opencode-desktop"
cask "lm-studio"
cask "ollama-app"
cask "emdash"
cask "open-design"
cask "block-buzz"
cask "typewhisper"
# Browsers
cask "brave-browser"
cask "google-chrome"
cask "firefox"
cask "librewolf"
cask "zen"
# Productivity
cask "1password"
cask "raycast"
cask "linear"
cask "obsidian"
cask "ticktick"
cask "granola"
cask "mailspring"
cask "libreoffice"
cask "affinity"
cask "numi"
cask "mmex"
# Communication
cask "discord"
cask "element"
cask "telegram-desktop"
cask "whatsapp"
cask "microsoft-teams"
# Menu bar and system utilities
cask "hiddenbar"
cask "meetingbar"
cask "stats"
cask "monitorcontrol"
cask "rectangle-pro"
cask "appcleaner"
cask "latest"
cask "glinford/tap/dns-easy-switcher", trusted: true
cask "localsend"
cask "logi-options+"
cask "radio-silence"
cask "reikey"
cask "yubico-authenticator"
cask "protonvpn"
cask "teamviewer"
cask "balenaetcher"
# Media and games
cask "spotify"
cask "vlc"
cask "obs"
cask "jellyfin-media-player"
cask "transmission"
cask "steam"
cask "minecraft"
# App Store
mas "1Password for Safari", id: 1569813296
mas "AdBlock", id: 1402042596
mas "Amphetamine", id: 937984704
mas "Bitwarden", id: 1352778147
mas "Boop", id: 1518425043
mas "Color Picker", id: 1545870783
mas "DeArrow", id: 6451469297
mas "Disk Drill Media Recovery", id: 431224317
mas "DuckDuckGo Privacy for Safari", id: 1482920575
mas "Fresco", id: 1251572132
mas "GarageBand", id: 682658836
mas "Gestimer", id: 990588172
mas "Keka", id: 470158793
mas "Keynote", id: 409183694
mas "Keynote Creator Studio", id: 361285480
mas "Maccy", id: 1527619437
mas "Microsoft Excel", id: 462058435
mas "Microsoft PowerPoint", id: 462062816
mas "Microsoft Word", id: 462054704
mas "Numbers", id: 409203825
mas "Numbers Creator Studio", id: 361304891
mas "Pages", id: 409201541
mas "Pages Creator Studio", id: 361309726
mas "Pixelmator Pro", id: 1289583905
mas "Slack", id: 803453959
mas "SponsorBlock", id: 1573461917
mas "Tailscale", id: 1475387142
mas "Termius 2", id: 1176074088
mas "TextSniper", id: 1528890965
mas "Xcode", id: 497799835
+33 -31
View File
@@ -33,13 +33,10 @@ This repository contains personal dotfiles for configuring a complete developmen
Before installing these dotfiles, ensure you have the following:
- **Operating System**: macOS (Darwin platform)
- **Package Manager**: [Homebrew](https://brew.sh/) installed
- **Shell**: Zsh (default on modern macOS)
- **Git**: Version control system
- **Node.js**: Required for many Neovim plugins and LSP servers
- **Python**: Required for certain development tools and Neovim plugins
- **Rust**: (Optional) Required for some tools like ripgrep, fd, etc.
- **Operating System**: macOS (Apple Silicon or Intel)
- **Git**: To clone this repository. macOS offers to install it with the Command Line Tools the first time you run `git`
`setup-mac.sh` installs everything else: the Command Line Tools, Homebrew, Node.js, Python, Rust and the rest.
## Installation
@@ -53,17 +50,28 @@ Before installing these dotfiles, ensure you have the following:
cd ~/dotfiles
```
2. **Run the setup script**:
2. **Preview, then run the setup script**:
```bash
./setup-mac.sh --dry-run
./setup-mac.sh
```
3. **Create symbolic links** (if not handled by setup script):
```bash
ln -s ~/dotfiles/.config ~/.config
ln -s ~/dotfiles/.zshrc ~/.zshrc
ln -s ~/dotfiles/.tmux.conf ~/.tmux.conf
```
The script is safe to rerun: each step checks what is already in place and only does the missing work. A failed step is reported at the end while the other steps still run. Name steps to run only those, for example `./setup-mac.sh links` after adding a config.
| Step | What it does |
|------|--------------|
| `xcode` | Installs the Xcode Command Line Tools |
| `homebrew` | Installs Homebrew and loads it from `~/.zprofile` |
| `brew` | Installs the CLI tools, fonts and terminals in `Brewfile` |
| `links` | Fetches the Neovim submodule on a fresh clone and symlinks the configs and `.local/bin` scripts into `$HOME`, backing up anything it replaces to `~/.local/state/dotfiles/backups/` |
| `shell` | Installs oh-my-zsh and the tmux plugins |
| `runtimes` | Node with nvm and the global npm packages, Rust, Python with pyenv, poetry, and Flutter with fvm |
| `ai` | Installs omo, Claude Code, OpenCode and Codex, then runs `link-ai-configs.sh` |
| `apps` | Installs the GUI and App Store apps in `Brewfile.apps` |
`brew` and `apps` never upgrade packages that are already installed. `apps` skips apps already in `/Applications` that Homebrew did not install, and its App Store entries need you signed in to the App Store first. To list what is installed but missing from `Brewfile`, run `brew bundle dump --file=- | diff Brewfile -`.
A few things still need you afterwards, such as signing in to the App Store and the AI tools, and turning on the 1Password SSH agent. The script lists them when it finishes.
### Manual Installation
@@ -83,7 +91,7 @@ ln -s ~/dotfiles/.zshrc ~/.zshrc
### AI configuration restore
After installing Node.js, OpenCode, Claude Code, and OMC, restore the selected AI configuration files separately from the package installer:
The `ai` step of `setup-mac.sh` runs this script. To run it on its own after installing Node.js, OpenCode, Claude Code, and OMC:
```bash
bash ~/dotfiles/link-ai-configs.sh --dry-run
@@ -99,14 +107,7 @@ The script creates individual relative symlinks, skips existing correct links, a
Credentials, `settings.local.json`, plugin installations, unrelated user skills, histories, caches, and runtime state are not copied into this repository or managed by this script. The selected Impeccable, Vercel web-design-guidelines and frontend-workflow skills are managed individually; see [the GPT visual workflow](.config/opencode/README.md) for provenance, verification and rollback. Install other desired plugins and portable skills separately. OpenCode's local MCP servers are referenced through `{env:HOME}`, so the paths are portable, but the servers themselves (headroom, agcanvas, uvx, ast-grep-mcp, portfolio-mcp) must be installed at those locations on a new machine.
The OmO hooks, slash commands and workspace scripts are not linked by the script yet. Link them by hand:
```bash
ln -s ../../dotfiles/.config/omo/hooks.json ~/.omo/agent/hooks.json
ln -s ../../dotfiles/.config/omo/prompts ~/.omo/agent/prompts
ln -s ../../dotfiles/.local/bin/owt ~/.local/bin/owt
ln -s ../../dotfiles/.local/bin/omo-notify ~/.local/bin/omo-notify
```
The OmO hooks and slash commands (`~/.omo/agent/hooks.json` and `~/.omo/agent/prompts`) and the workspace scripts in `~/.local/bin` are linked by the `links` step of `setup-mac.sh` instead.
Restart OpenCode and Claude Code after applying changes. On first use, Claude may ask to approve the shared preferences import. Updates can rewrite generated configuration or replace symlinks: review the live files and repository diff before rerunning the link script, which backs up divergent live files rather than merging them. No plugin versions or model choices are changed by this restore step.
@@ -156,7 +157,7 @@ Key features:
### Development Tools
- **tmux** (`.tmux.conf`): Terminal multiplexer with custom key bindings; sets the terminal title to the session name
- **tmux** (`.tmux.conf`): Terminal multiplexer with custom key bindings; sets the terminal title to the session name, after the omo status marker when there is one
- **Zed** (`.config/zed/keymap.json`): Editor key bindings. Settings stay local because they reference machine-specific agent paths
- **tmux server** (`.tmux-server.conf`): Lightweight tmux config for remote servers
- **Git**: Global gitignore patterns (`.rgignore`, `.gitignore`)
@@ -195,7 +196,7 @@ MCP servers: Playwright, Chrome DevTools, Linear, reoclo, time, ast-grep, headro
#### OmO (`.config/omo/`)
- `omo.jsonc` (linked as `~/.omo/omo.jsonc`): agent and category model routing for OpenCode and the native harness.
- `hooks.json` (linked as `~/.omo/agent/hooks.json`): runs `omo-notify` for a macOS notification when a session stops or needs attention.
- `hooks.json` (linked as `~/.omo/agent/hooks.json`): runs `omo-notify` when you send a prompt, when a session stops and when it asks you something, for the sound, the notification and the coloured tab marker.
- `prompts/` (linked as `~/.omo/agent/prompts`): slash commands for `owt` workspaces.
- `/linear-sync [notes]` posts a progress comment on the workspace's Linear issues and sets them to In Progress, or In Review once a PR is open.
- `/owt-done [notes]` finishes a workspace after its PR merges: it comments on each issue and moves it to Done, then runs `owt done --detach`. It asks before discarding uncommitted or unmerged work.
@@ -208,12 +209,16 @@ MCP servers: Playwright, Chrome DevTools, Linear, reoclo, time, ast-grep, headro
owt new # random city name, e.g. ~/worktrees/<repo>/lagos on branch feature/lagos
owt new PRA-107 PRA-108 # same, and tell the agent which Linear issues to start on
owt ls # workspaces and their current branches
owt all # workspaces of every repo, with branches and source checkouts (from anywhere)
owt cd # jump into a workspace with fzf; owt cd lagos goes straight there
owt open lagos # reopen the agent in a workspace
owt done # remove the workspace you are in once its work is merged
```
`owt new` starts from the latest `origin` default branch, copies local `.env` files, installs dependencies, and opens a tmux window running omo with the workspace conventions: rename the placeholder branch to `feature/<keys>-<slug>` before pushing, and keep the Linear issues' statuses in sync. Workspaces share the main checkout's omo memory through a link under `~/.omo/memory/agents/`.
`owt cd` works from any directory. Inside a repository the picker starts filtered to that repo's workspaces and jumps straight in when only one matches. The `owt` function in `.zshrc` performs the `cd`; without it, `owt cd` only prints the path.
`owt done` refuses to discard work unless you pass `--force`. Work counts as merged when the branch is part of the default branch or its Gitea PR is merged (checked with `tea`). It then deletes the remote and local branch, removes the worktree and its memory link, and closes tmux windows open in it. `OWT_ROOT` (default `~/worktrees`) and `OWT_AGENT` (default `omo`) override the defaults; `owt --help` lists every option.
#### Neovim AI Integration
@@ -242,14 +247,11 @@ Custom scripts and binaries are stored in `.local/bin/`:
- `claude-tmux`: Claude integration with tmux
- `cldir`: Change directory with Claude context
- `owt`: Git worktree workspaces for coding agents (see [Agent workspaces](#agent-workspaces-owt))
- `omo-notify`: macOS notification for OmO's Stop and Notification hooks
- `omo-notify`: Sound, tab marker and notification for OmO's hooks. Ghostty posts the notification, so clicking it opens the session's tab and pane
- `omo-status`: Coloured marker in front of the Ghostty tab title for each tmux session running omo: 🔴 stopped with an error, 🟡 waiting for your answer, 🟢 finished, 🔵 working. A tab with several agents shows the most urgent one; focusing the tab clears red, amber and green
- `docker-nuke`: Force-restart a wedged Docker Desktop, capturing diagnostics to `~/.local/state/docker-nuke/` first (`--dry-run` to preview, `--no-restart` to leave it down, `--deep` for root helpers)
Ensure this directory is in your PATH:
```bash
export PATH="$HOME/dotfiles/.local/bin:$PATH"
```
The `links` step of `setup-mac.sh` links each script into `~/.local/bin`, which `.zshrc` puts on your PATH. `pbcopy` and `pbpaste` are OSC 52 clipboard shims for headless servers, so they are not linked on macOS.
### Local Servers
+428 -209
View File
@@ -1,239 +1,458 @@
#!/bin/bash
#!/usr/bin/env bash
# Set up a Mac from this dotfiles repo. Safe to rerun: every step checks what
# is already in place and only does the missing work.
#
# ./setup-mac.sh run every step in order
# ./setup-mac.sh links shell run only the named steps
# ./setup-mac.sh --dry-run show what would change without changing it
#
# Steps: xcode homebrew brew links shell runtimes ai apps
# Packages live in Brewfile (CLI tools, fonts, terminals) and Brewfile.apps
# (GUI and App Store apps). Written for the bash 3.2 that ships with macOS.
set -uo pipefail
echo "Setting up your Mac..."
# Check if Homebrew is installed
if command -v brew &> /dev/null; then
echo "Homebrew is installed."
else
echo "Homebrew is not installed."
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Check if Git is installed
STEPS="xcode homebrew brew links shell runtimes ai apps"
DOTFILES=$(dirname "$(realpath "${BASH_SOURCE[0]}")")
NVM_DIR=$HOME/.config/nvm
NODE_VERSION=22
PYTHON_VERSION=3.11
NPM_GLOBALS="oh-my-claude-sisyphus 9router better-sqlite3 sql.js systray2"
DRY_RUN=false
if command -v git &> /dev/null; then
echo "Git is installed."
else
echo "Git is not installed."
echo "Installing Git..."
brew install git
fi
# Repo paths linked into $HOME, as "source:target" when the target differs.
# Paths missing from the repo (gitignored local files such as .env) are skipped.
# The AI configs are linked separately by link-ai-configs.sh in the ai step.
LINKS="
.zshrc
.tmux.conf
.tmux-server.conf
.rgignore
.withcontextconfig.jsonc
.withcontextignore
.env
.envrc
.opencommit
AGENTS.md
docs
local_servers
.superpowers
.config/nvim
.config/ghostty
.config/wezterm
.config/direnv
.config/starship.toml
.config/ohmyposh/zen.toml
.config/zellij/config.kdl
.config/zed/keymap.json
.config/zed/settings.json
.config/opencode/agent
.config/opencode/command
.config/omo/hooks.json:.omo/agent/hooks.json
.config/omo/prompts:.omo/agent/prompts
"
# Check if coreutils is installed
say() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
if command -v gdate &> /dev/null; then
echo "coreutils is installed."
else
echo "coreutils is not installed."
echo "Installing coreutils..."
brew install coreutils
fi
run() {
if $DRY_RUN; then info "would run: $*"; else "$@"; fi
}
# Check if OhMyPosh is installed
run_remote() {
local url=$1 script
shift
if $DRY_RUN; then
info "would run the installer from $url $*"
return 0
fi
script=$(curl -fsSL "$url") || return 1
/bin/bash -c "$script" "$url" "$@"
}
if command -v oh-my-posh &> /dev/null; then
echo "OhMyPosh is installed."
else
echo "OhMyPosh is not installed."
echo "Installing OhMyPosh..."
brew install jandedobbeleer/oh-my-posh/oh-my-posh
fi
load_brew_env() {
local brew
for brew in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [[ -x $brew ]]; then
eval "$("$brew" shellenv)"
return 0
fi
done
return 1
}
# Check if asdf is installed
if command -v asdf &> /dev/null; then
echo "asdf is installed."
else
echo "asdf is not installed."
echo "Installing asdf..."
brew install asdf
describe() {
case $1 in
xcode) echo "Xcode Command Line Tools" ;;
homebrew) echo "Homebrew" ;;
brew) echo "CLI tools, fonts and terminals (Brewfile)" ;;
links) echo "Dotfile symlinks" ;;
shell) echo "oh-my-zsh and tmux plugins" ;;
runtimes) echo "Node, Rust, Python and Flutter" ;;
ai) echo "AI coding tools and their configs" ;;
apps) echo "GUI and App Store apps (Brewfile.apps)" ;;
esac
}
echo "Adding asdf plugins..."
asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git
fi
step_xcode() {
if xcode-select -p >/dev/null 2>&1; then
info "Command Line Tools already installed"
return 0
fi
run xcode-select --install
if ! $DRY_RUN; then
info "Finish the Command Line Tools installer window; waiting for it"
until xcode-select -p >/dev/null 2>&1; do sleep 5; done
fi
}
# Check if NodeJS is installed
if command -v node &> /dev/null; then
echo "NodeJS is installed."
else
echo "NodeJS is not installed."
echo "Installing NodeJS..."
brew install node
fi
step_homebrew() {
local prefix=/usr/local
if [[ $(uname -m) == arm64 ]]; then prefix=/opt/homebrew; fi
if [[ -x $prefix/bin/brew ]]; then
info "Homebrew already installed"
else
run_remote https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh
fi
# Login shells get brew on PATH from .zprofile; .zshrc relies on it.
if grep -qs 'brew shellenv' "$HOME/.zprofile"; then
info "Homebrew already loaded by ~/.zprofile"
elif $DRY_RUN; then
info "would add Homebrew's shellenv to ~/.zprofile"
else
# shellcheck disable=SC2016 # the $(...) is meant for .zprofile, not here
printf '\n# Homebrew\neval "$(%s/bin/brew shellenv)"\n' "$prefix" >>"$HOME/.zprofile"
info "added Homebrew's shellenv to ~/.zprofile"
fi
}
# Check if Neovim is installed
# Installs a Brewfile without upgrading what is already installed. In dry-run
# mode it lists what is missing instead.
bundle() {
if ! have brew; then
warn "Homebrew is missing; run the homebrew step first"
return 1
fi
if $DRY_RUN; then
info "missing from ${1##*/}:"
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle check --file="$1" --verbose 2>&1 |
grep -v '^The Brewfile' | sed 's/^/ /' || true
return 0
fi
HOMEBREW_BUNDLE_NO_UPGRADE=1 brew bundle install --file="$1"
}
if command -v nvim &> /dev/null; then
echo "Neovim is installed."
else
echo "Neovim is not installed."
echo "Installing Neovim..."
brew install neovim
fi
step_brew() {
bundle "$DOTFILES/Brewfile"
}
# Check if WezTerm is installed
BACKUP_DIR=""
KEPT=0
# Links ~/<target> to the repo's <source>, backing up whatever was there.
link() {
local src=$DOTFILES/$1 dst=$HOME/$2 parent
if [[ ! -e $src ]]; then
info "skipped ~/$2 ($1 is not in the repo)"
return 0
fi
if [[ $src -ef $dst ]]; then
KEPT=$((KEPT + 1))
return 0
fi
if $DRY_RUN; then
if [[ -e $dst || -L $dst ]]; then
info "would back up ~/$2 and link it to $1"
else
info "would link ~/$2 to $1"
fi
return 0
fi
parent=$(dirname "$dst")
mkdir -p "$parent"
if [[ -e $dst || -L $dst ]]; then
if [[ -z $BACKUP_DIR ]]; then
BACKUP_DIR=$HOME/.local/state/dotfiles/backups/$(date +%Y-%m-%dT%H-%M-%S)
fi
mkdir -p "$(dirname "$BACKUP_DIR/$2")"
mv "$dst" "$BACKUP_DIR/$2"
info "backed up ~/$2 to $BACKUP_DIR/$2"
fi
ln -s "$(python3 -c 'import os, sys; print(os.path.relpath(sys.argv[1], os.path.realpath(sys.argv[2])))' "$src" "$parent")" "$dst"
info "linked ~/$2"
}
if command -v wezterm &> /dev/null; then
echo "WezTerm is installed."
else
echo "WezTerm is not installed."
echo "Installing WezTerm..."
brew install wezterm
fi
step_links() {
# Fetch the nvim config on a fresh clone only. An initialised submodule is
# left alone so whatever branch it has checked out is never reset.
if git -C "$DOTFILES" submodule status .config/nvim | grep -q '^-'; then
run git -C "$DOTFILES" submodule update --init --recursive .config/nvim
fi
local entry script name
for entry in $LINKS; do
link "${entry%%:*}" "${entry#*:}"
done
for script in "$DOTFILES"/.local/bin/*; do
name=${script##*/}
case $name in
pbcopy | pbpaste) continue ;; # OSC 52 clipboard shims for headless servers
esac
if [[ -x $script ]]; then link ".local/bin/$name" ".local/bin/$name"; fi
done
info "$KEPT links already in place"
}
step_shell() {
if [[ -d $HOME/.oh-my-zsh ]]; then
info "oh-my-zsh already installed"
else
# --keep-zshrc leaves the linked .zshrc alone.
run_remote https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh --unattended --keep-zshrc
fi
local tpm=$HOME/.tmux/plugins/tpm
if [[ -d $tpm ]]; then
info "tpm already installed"
else
run git clone --depth 1 https://github.com/tmux-plugins/tpm "$tpm"
fi
# Installs the .tmux.conf plugins that are not there yet.
run "$tpm/bin/install_plugins"
}
# Ensure fira font is installed
install_node() {
if [[ -s $NVM_DIR/nvm.sh ]]; then
info "nvm already installed"
else
run git clone --quiet https://github.com/nvm-sh/nvm.git "$NVM_DIR"
if $DRY_RUN; then
info "would install Node $NODE_VERSION as the nvm default, plus npm globals: $NPM_GLOBALS"
return 0
fi
git -C "$NVM_DIR" checkout --quiet "$(git -C "$NVM_DIR" describe --abbrev=0 --tags --match 'v[0-9]*' "$(git -C "$NVM_DIR" rev-list --tags --max-count=1)")"
fi
# nvm does not work under set -eu, so it gets a subshell of its own.
(
set +eu
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
if nvm which "$NODE_VERSION" >/dev/null 2>&1; then
info "Node $NODE_VERSION already installed"
else
run nvm install "$NODE_VERSION" || exit 1
fi
if [[ $(nvm version default) != "$(nvm version "$NODE_VERSION")" ]]; then
run nvm alias default "$NODE_VERSION" || exit 1
fi
nvm use --silent "$NODE_VERSION" >/dev/null 2>&1 || exit 0 # dry run before Node exists
for pkg in $NPM_GLOBALS; do
if npm ls -g --depth=0 "$pkg" >/dev/null 2>&1; then
info "npm $pkg already installed"
else
run npm install -g "$pkg" || exit 1
fi
done
)
}
brew install font-fira-code-nerd-font
install_rust() {
if [[ -x $HOME/.cargo/bin/rustup ]]; then
info "Rust already installed"
else
run_remote https://sh.rustup.rs -y --no-modify-path
fi
if [[ -x $HOME/.cargo/bin/cargo-xwin ]]; then
info "cargo-xwin already installed"
else
run "$HOME/.cargo/bin/cargo" install --locked cargo-xwin
fi
}
# Check if Zsh is installed
install_python() {
local current
current=$(pyenv global 2>/dev/null || true)
if [[ -n $current && $current != system ]]; then
info "pyenv global Python already set ($current)"
elif $DRY_RUN; then
info "would install the latest Python $PYTHON_VERSION with pyenv and make it the global default"
else
pyenv install --skip-existing "$PYTHON_VERSION"
pyenv global "$(pyenv latest "$PYTHON_VERSION")"
fi
if have poetry; then
info "poetry already installed"
else
run uv tool install poetry
fi
# .zshrc adds ~/.zfunc to fpath for these completions.
if [[ -f $HOME/.zfunc/_poetry ]]; then
info "poetry completions already installed"
elif $DRY_RUN; then
info "would write poetry's zsh completions to ~/.zfunc/_poetry"
else
mkdir -p "$HOME/.zfunc"
poetry completions zsh >"$HOME/.zfunc/_poetry"
fi
}
if command -v zsh &> /dev/null; then
echo "Zsh is installed."
else
echo "Zsh is not installed."
echo "Installing Zsh..."
brew install zsh
install_flutter() {
if [[ -e $HOME/fvm/default ]]; then
info "Flutter already installed (fvm global)"
else
run fvm install stable
run fvm global stable
fi
}
echo "Setting Zsh as default shell..."
sudo sh -c "echo $(which zsh) >> /etc/shells"
fi
step_runtimes() {
install_node
install_rust
install_python
install_flutter
}
# Check if Generic Colorizer is installed
step_ai() {
if have omo; then info "omo already installed"; else run bun add -g omo-ai; fi
if have claude; then
info "Claude Code already installed"
else
run_remote https://claude.ai/install.sh
fi
if have opencode; then
info "OpenCode already installed"
else
run_remote https://opencode.ai/install --no-modify-path
fi
if have codex; then info "Codex already installed"; else run brew install --cask codex; fi
# link-ai-configs.sh needs Node from the runtimes step.
local args=""
if $DRY_RUN; then args=--dry-run; fi
(
set +eu
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
bash "$DOTFILES/link-ai-configs.sh" $args
)
}
if command -v grc &> /dev/null; then
echo "Generic Colorizer is installed."
else
echo "Generic Colorizer is not installed."
echo "Installing Generic Colorizer..."
brew install grc
fi
# Prints the casks in a Brewfile whose app is already installed, but not by
# Homebrew. Installing those fails with "It seems there is already an App".
unmanaged_casks() {
local cask casks="" managed json name kind id
if ! have jq; then
warn "jq is missing, so apps installed outside Homebrew cannot be detected"
return 0
fi
managed=$(brew list --cask)
while read -r cask; do
if ! grep -qx "${cask##*/}" <<<"$managed"; then casks="$casks $cask"; fi
done < <(sed -nE 's/^cask "([^"]+)".*/\1/p' "$1")
if [[ -z $casks ]]; then return 0; fi
# Casks from taps that are not trusted yet cannot be looked up; on a fresh
# Mac there is nothing to skip anyway.
# shellcheck disable=SC2086 # one argument per cask
if ! json=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info --cask --json=v2 $casks 2>/dev/null); then
warn "could not look up the casks, so none are skipped"
return 0
fi
# Each cask yields the app names it installs (source and renamed target),
# or, for pkg and installer casks, its display name plus its pkg receipts.
jq -r '
.casks[] | .token as $name |
[.artifacts[] | select(has("app")) | .app | (.[0] | split("/") | last),
(.[1]? | objects | .target // empty | split("/") | last)] as $apps |
if ($apps | length) > 0 then $apps[] | "\($name)\tapp\t\(.)"
else "\($name)\tapp\t\(.name[0]).app",
(.artifacts[] | select(has("uninstall")) | .uninstall[] | .pkgutil? // empty
| (if type == "array" then .[] else . end) | "\($name)\tpkg\t\(.)")
end' <<<"$json" |
while IFS=$'\t' read -r name kind id; do
if [[ $kind == app && (-e /Applications/$id || -e $HOME/Applications/$id) ]] ||
{ [[ $kind == pkg && -n $(pkgutil --pkgs="$id" 2>/dev/null) ]]; }; then
echo "$name"
fi
done | sort -u | tr '\n' ' '
}
# Check if bat is installed
step_apps() {
local skip
if ! have brew; then
warn "Homebrew is missing; run the homebrew step first"
return 1
fi
skip=$(unmanaged_casks "$DOTFILES/Brewfile.apps")
skip=${skip% }
if [[ -n $skip ]]; then
info "skipping casks for apps installed outside Homebrew: $skip"
fi
HOMEBREW_BUNDLE_CASK_SKIP=$skip bundle "$DOTFILES/Brewfile.apps"
}
if command -v bat &> /dev/null; then
echo "bat is installed."
else
echo "bat is not installed."
echo "Installing bat..."
brew install bat
fi
usage() {
sed -n '2,/^$/s/^# \{0,1\}//p' "${BASH_SOURCE[0]}"
}
# Check if eza is installed
main() {
local arg step status selected="" failed=""
for arg in "$@"; do
case $arg in
-n | --dry-run) DRY_RUN=true ;;
-h | --help)
usage
return 0
;;
-*)
warn "unknown option: $arg"
usage
return 2
;;
*)
if [[ " $STEPS " != *" $arg "* ]]; then
warn "unknown step: $arg (steps: $STEPS)"
return 2
fi
selected="$selected $arg"
;;
esac
done
if [[ $(uname -s) != Darwin ]]; then
warn "setup-mac.sh only runs on macOS"
return 1
fi
if [[ -z $selected ]]; then selected=$STEPS; fi
if command -v eza &> /dev/null; then
echo "eza is installed."
else
echo "eza is not installed."
echo "Installing eza..."
brew install eza
fi
# Tools from non-Homebrew installers live here; the steps look for them.
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.opencode/bin:$HOME/.cargo/bin:$PATH"
load_brew_env || true
if $DRY_RUN; then say "Dry run: nothing will be changed"; fi
# Install global pnpm package
if command -v pnpm &> /dev/null; then
echo "pnpm is installed."
else
echo "pnpm is not installed."
echo "Installing pnpm..."
npm install -g pnpm
fi
for step in $selected; do
say "$step: $(describe "$step")"
# Each step runs in a subshell with errexit so it stops at its first
# failure. Testing the subshell directly would switch errexit off.
(
set -e
"step_$step"
)
status=$?
if [[ $status -ne 0 ]]; then
failed="$failed $step"
warn "$step failed; fix the error above, then rerun ./setup-mac.sh $step"
fi
load_brew_env || true
done
# Check if fzf is installed
say "Done"
if [[ -n $failed ]]; then warn "failed steps:$failed"; fi
if ! $DRY_RUN && [[ $selected == "$STEPS" ]]; then
info "Steps that need you:"
info "- Sign in to the App Store, then rerun ./setup-mac.sh apps for the App Store apps"
info "- 1Password: turn on the SSH agent (Settings > Developer); .zshrc uses it for SSH"
info "- Sign in: gh auth login, then Claude Code, Codex and OpenCode"
info "- Open a new terminal to load the new shell setup"
fi
[[ -z $failed ]]
}
if command -v fzf &> /dev/null; then
echo "fzf is installed."
else
echo "fzf is not installed."
echo "Installing fzf..."
brew install fzf
fi
# Check if ripgrep is installed
if command -v rg &> /dev/null; then
echo "ripgrep is installed."
else
echo "ripgrep is not installed."
echo "Installing ripgrep..."
brew install ripgrep
fi
# Check if poetry is installed
if command -v poetry &> /dev/null; then
echo "poetry is installed."
else
echo "poetry is not installed."
echo "Installing poetry..."
curl -sSL https://install.python-poetry.org | python3 -
fi
# Check if gh is installed
if command -v gh &> /dev/null; then
echo "gh is installed"
else
echo "gh is not installed"
echo "Installing gh with brew..."
brew install gh
# Check if pyenv is installed
if command -v pyenv &> /dev/null; then
echo "pyenv is installed."
else
echo "pyenv is not installed."
echo "Installing pyenv with brew..."
brew install pyenv
# Check if pyenv-virtualenv is installed
if command -v pyenv &> /dev/null; then
echo "pyenv-virtualenv is installed."
else
echo "pyenv-virtualenv is not installed."
echo "Installing pyenv-virtualenv with brew..."
brew install pyenv-virtualenv
# Check if anaconda is installed
if command -v conda &> /dev/null; then
echo "anaconda is installed."
else
echo "anaconda is not installed."
echo "Installing anaconda..."
brew install --cask anaconda
fi
# Install Flutter version manager
if command -v fvm &> /dev/null; then
echo "fvm is installed."
else
echo "fvm is not installed."
echo "Installing fvm..."
curl -fsSL https://fvm.app/install.sh | bash
fi
# Install SDK manager
# Check if $HOME/.sdkman exists
if [ -d "$HOME/.sdkman" ]; then
echo "sdkman is installed."
else
echo "sdkman is not installed."
echo "Installing sdkman..."
curl -s "https://get.sdkman.io" | bash
fi
# Install Aerospace
if command -v aerospace &> /dev/null; then
echo "aerospace is installed."
else
echo "aerospace is not installed."
echo "Installing aerospace..."
brew install --cask nikitabobko/tap/aerospace
fi
main "$@"