Gives the model the user's local date and time so it never runs `date`, and shows the local time in the status line

My personal macOS dotfiles. Originally based on https://jooooel.com/sync-dotfiles-using-git/
All the dotfiles start with an underscore (_zshrc, _gitconfig, ...) so they aren't hidden when browsing the repo. This makes ls behave nicely. The install script maps each one to its real dotted location.
Do these in order. Steps 1-6 get a working machine; step 7 covers the things no script can do (permissions, sign-ins) and must be done by hand.
Gives you git so you can clone this repo. (Homebrew's installer in step 2 also triggers this, so you can skip straight to step 2 if you prefer.)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
The installer prints a "Next steps" note telling you to add Homebrew to your PATH. On Apple Silicon that is:
eval "$(/opt/homebrew/bin/brew shellenv)"
Run that now so brew works in the current shell. (Once step 5 links ~/.zshrc and you open a new terminal, Homebrew is on PATH for good.)
It must live at ~/dotfiles (some configs reference that absolute path).
git clone https://github.com/jquintus/dotfiles ~/dotfiles
cd ~/dotfiles
Reads the Brewfile in the repo root: CLI tools, casks (GUI apps), and taps.
brew bundle install
Note: the Brewfile has no Mac App Store (mas) entries, so App-Store-only apps are not installed here. Install those by hand from the App Store (see step 7).
Links every config into place. No dependencies beyond bash, so it is safe to run even before brew bundle if you like.
./scripts/install-mac.sh
The one-to-one links are declared in scripts/links.manifest (a source | target | description table) that the script loops over. To add a config later, add a line to the manifest rather than editing the script. bin/* is linked by a glob, so new scripts there are picked up automatically.
The script also installs Claude Code, which is deliberately not in the Brewfile: the cask lags upstream and never self-updates. The native installer drops a self-updating launcher in ~/.local/bin/claude (already on PATH), and the step is skipped when that launcher already exists. It only needs curl, and warns rather than fails if the download does not work. Confirm with claude doctor: it should report native and Auto-updates: enabled.
It installs Codex the same way and for a related reason: the codex cask is current with upstream, but it is not the standalone install the background app-server daemon starts from, so codex agents refuses to run under it. The official installer lands a self-updating build in ~/.codex/packages/standalone and links it into ~/.local/bin/codex. Confirm with codex doctor: the runtime line should say standalone, not brew.
It installs the Cursor CLI the same way, for the first reason rather than the second: the cursor-cli cask pins a build inside the Caskroom, which fights the binary's own auto-update. The official installer lands a self-updating build in ~/.local/share/cursor-agent/versions and links it into ~/.local/bin twice, as cursor-agent and as the much more collidable agent. Confirm with cursor-agent about.
Copilot's CLI is the exception that stays in the Brewfile. Its cask is marked auto_updates, so the binary updates itself with copilot update and brew never has to catch up.
Last, it runs bin/codex-sync, which hands Codex the same subagents, skills and global instructions Claude Code runs under. See "Codex" below for what that generates and when to rerun it.
Open a new terminal (or source ~/.zshrc) after this so the new shell config takes effect.
Sets system preferences via defaults write (key repeat, menu-bar clock seconds, muted startup chime, disabled F11 Show Desktop, and more). Kept separate from linking so symlinking never mutates OS state. Idempotent, safe to re-run. Prompts for sudo once (to mute the startup chime, which lives in firmware).
./scripts/macos-defaults.sh
Some changes need a logout or restart to fully take effect.
These require clicking through UI, signing in, or granting OS permissions. There is no way to automate them, so run through the checklist by hand:
gh auth login. The token lives in gh/hosts.yml, which is gitignored and machine-local by design, so it is never in this repo.claude/settings.json lists the marketplaces and enables the plugins, but declaring one does not fetch it. Claude Code prompts on first run; to skip the prompt: `` claude plugin install search-sessions@search-sessions ` The search-sessions` binary itself comes from the Brewfile. The plugin is the separate piece that lets Claude search session history on your behalf.codex login. install-mac.sh installs and configures the CLI, but the credentials land in ~/.codex/auth.json, which is machine-local and never in this repo. Confirm with codex doctor.cursor-agent login, which opens a browser. Check it took with cursor-agent status.copilot login. It can also read a GITHUB_TOKEN from the environment instead, but the interactive login is the simpler path.hammerspoon/.hammerspoon/meet.lua. (The matching Chrome-side setting, "Allow JavaScript from Apple Events," is set by macos-defaults.sh and takes effect on Chrome's next launch.)karabiner_grabber and Karabiner-Elements. Without these, Caps Lock silently stays Caps Lock and every Hyper binding in hammerspoon/launcher.lua is dead. See karabiner/README.md.brew install --cask logi-options+, deliberately not in the Brewfile — it was installed by hand, so a brew bundle dump --force would drop the line again). The MX Anywhere 3 needs it for two things, both of which must be clicked in by hand because Options+ keeps its settings in its own sqlite database:Ctrl+- and Ctrl+Shift+-, so they navigate back and forward there. VS Code ignores mouse thumb buttons natively.Both of these have to live in Options+ rather than Karabiner, and the agent has to keep running for them to work. karabiner/README.md explains why.
~/.pgpass and any real DB connection details (see zsh/_zshrc-aliases).zsh/_zshrc-<LocalHostName> if it exists. Find this machine's name with scutil --get LocalHostName, then create that file for host-specific tweaks.The Brewfile is a snapshot of installed Homebrew taps, formulae, and casks.
# Install everything from the Brewfile (e.g. on a new machine)
brew bundle install
# Re-snapshot after installing/removing packages
brew bundle dump --force
# Check whether all Brewfile entries are installed
brew bundle check
# Show installed packages NOT in the Brewfile (add --force to uninstall them)
brew bundle cleanup
scripts/macos-defaults.sh holds every defaults write (and a few related nvram / duti calls). Each setting is annotated with where it lives in System Settings. Run it with ./scripts/macos-defaults.sh; it is idempotent.
Some apps keep their user config outside $HOME (e.g. under Application Support). Those live in their own repo dirs and are symlinked into place by install-mac.sh:
| Repo dir | Symlinked to |
|---|---|
vscode/settings.json | ~/Library/Application Support/Code/User/settings.json |
claude/settings.json | ~/.claude/settings.json |
Because they're symlinks, editing the app's settings (via its UI or by hand) writes straight back to this repo. So after changing a setting, remember to git commit && git push here. The diff shows up under vscode/ or claude/.
Note: the Claude source dir is claude/, not .claude/, on purpose. A .claude/settings.json would be read as project settings whenever Claude runs in this repo (double-registering hooks). Keep it as claude/.
Codex reads the same ideas as Claude Code out of a different shape of config, so bin/codex-sync translates rather than symlinks. Rerun it after editing claude/CLAUDE.md or anything in claude/agents/:
codex-sync
| What | ~/.codex/... | How |
|---|---|---|
| Global instructions | AGENTS.md | generated from claude/CLAUDE.md |
| Subagents | agents/*.toml | generated from claude/agents/*.md |
| Skills | skills/<name> | symlinked to ~/.claude/skills/<name> |
CLAUDE.md as fallback | config.toml | one key, written once and never rewritten |
Only the skills are symlinks, because SKILL.md is the same format in both tools, so edits flow both ways. The other two have to be generated: a Claude subagent is YAML frontmatter plus markdown, and a Codex custom agent is a TOML file with name, description and developer_instructions. Anything the script generates carries a marker line in its first line, and it only ever deletes files carrying that marker or symlinks pointing into ~/.claude.
Two translations worth knowing about. Codex has no @-import, so the machine-local ~/.claude/CLAUDE.work.md is inlined into the generated AGENTS.md (it stays out of this public repo either way).
And Codex has no per-agent tool allowlist. sandbox_mode = "read-only" looks like the equivalent and is not: it also cuts network access and makes every command ask for approval, which breaks an agent like archer that is supposed to read code and run scanners. So the Claude tools: line is copied in as a comment and nothing enforces it but the agent's own instructions — the same thing holding the line in the Claude prompt already.
Skills are read from ~/.claude/skills rather than from this repo, so Codex also sees skills installed on this machine that the repo does not track.
The install script symlinks both configs (~/.vimrc and ~/.config/nvim/init.lua), but plugins are managed separately. Each editor uses a different plugin manager, and both auto-bootstrap the manager on first launch.
# Installs vim-plug automatically if missing, then installs all plugins
vim +PlugInstall +qall
Or open vim and run :PlugInstall.
Packer auto-clones itself on the first launch, so this is a three-step dance:
nvim # 1. prints "Installing packer.nvim..." and clones it, then quit
nvim # 2. reopen so packer is on the runtime path
# 3. run :PackerSync inside nvim to install/sync all plugins
Note:
neo-vim/int.luahardcodes the repo path (/Users/jq/dotfiles/neo-vim/lua), so the Neovim config will break if the repo is moved. This is why step 3 clones to~/dotfiles.
A snapshot of /Applications (regenerate with ls /Applications):
hooks/register.ts 54 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { type Clock, DATE_FORMAT, note, parse, section, status } from './clock'
4
5const TTL_MS = 30_000
6const SECTION_ID = 'local-clock:time'
7
8let cached: { at: number; clock: Clock } | null = null
9
10async function now($: EngineInterface): Promise<Clock | null> {
11 const at = Date.now()
12 if (cached && at - cached.at < TTL_MS) return cached.clock
13 try {
14 const res = await $.process.run(['date', DATE_FORMAT])
15 const clock = res.exitCode === 0 ? parse(res.stdout) : null
16 if (clock) cached = { at, clock }
17
18 return clock ?? cached?.clock ?? null
19 } catch {
20 return cached?.clock ?? null
21 }
22}
23
24async function tick($: EngineInterface) {
25 const clock = await now($)
26 if (clock) $.ui.status(status(clock))
27}
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 void tick($)
32 $.clock.every(TTL_MS, () => void tick($))
33
34 return next(e)
35 })
36
37 // Day-level only: a per-minute system prompt would spend the prompt cache.
38 on('prompt.compose', async ($, e, next) => {
39 const out = await next(e)
40 const clock = await now($)
41 if (!clock) return out
42
43 return { sections: [...out.sections, { id: SECTION_ID, text: section(clock), scope: 'session' as const }] }
44 })
45
46 // The exact time rides each user message, where it stays in the transcript.
47 on('prompt.submit', async ($, e, next) => {
48 const clock = await now($)
49 if (!clock) return next(e)
50
51 return next({ ...e, context: [...(e.context ?? []), note(clock)] })
52 }).catch(($, e, next) => next(e))
53}
54hooks/clock.ts 49 lines1// `date` format whose output `parse` reads: weekday|month|day|year|time|zone.
2export const DATE_FORMAT = '+%a|%b|%-d|%Y|%-I:%M %p|%Z'
3
4export type Clock = {
5 weekday: string
6 month: string
7 day: string
8 year: string
9 time: string
10 zone: string
11}
12
13export function parse(stdout: string): Clock | null {
14 const parts = stdout.trim().split('|')
15 if (parts.length !== 6 || parts.some(p => !p)) return null
16 const [weekday, month, day, year, time, zone] = parts as [string, string, string, string, string, string]
17
18 return { weekday, month, day, year, time, zone }
19}
20
21export function day(c: Clock): string {
22 return `${c.weekday} ${c.month} ${c.day} ${c.year}`
23}
24
25// "Thu Oct 8 2026, 1:20 PM EDT"
26export function stamp(c: Clock): string {
27 return `${day(c)}, ${c.time} ${c.zone}`
28}
29
30// Changes once a day, so the system prompt (and the prompt cache behind it)
31// stays stable between turns.
32export function section(c: Clock): string {
33 return [
34 '# Local time',
35 `The user's local date is ${day(c)}, timezone ${c.zone}.`,
36 'Each user message carries the exact local time in a "Local time:" note.',
37 'Use these instead of running `date`.',
38 'Give times to the user in this local timezone, never UTC.',
39 ].join('\n')
40}
41
42export function note(c: Clock): string {
43 return `Local time: ${stamp(c)} (the user's local time; use it instead of running \`date\`).`
44}
45
46export function status(c: Clock): string {
47 return `${c.time} ${c.zone}`
48}
49