From 47a13881cec885d5dce588bc63a93e28b3fff3cd Mon Sep 17 00:00:00 2001 From: Joshua Goon <25201575+Jish2@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:43:41 -0700 Subject: [PATCH 1/4] karabiner: port config to karabiner.ts generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit karabiner-config.ts is now the source of truth for the Default profile's complex_modifications. `npm run build` regenerates ~/.config/karabiner/karabiner.json; `npm run verify` proves the generator reproduces the live file byte-for-byte (same pipeline: canonical key order + prettier-plugin-karabiner, i.e. the Karabiner-Elements json_writer format). All 10 rules ported 1:1 — including the three disabled ones (control+tab, MS RDP, home-row mods) and their per-manipulator descriptions. Also folded in what had drifted from main: - Ctrl+Space EN/Chinese input-source rule (was uncommitted) - scripts/toggle-input-source.sh (was untracked) - the earpods + ctrl+space rules re-keyed to the GUI's canonical order (content unchanged), matching commit 519cf5c's approach Rule order note: live config has Vim 8/11 (nav) before Vim 1/11 (toggle); kept as-is since the toggle's any-key exit must not shadow nav keys. .pretierrc override narrowed from *.json to karabiner.json (so the generator's package.json uses normal prettier JSON formatting), and the generator project lives in generator/ — one level down from the karabiner config dir — so the pre-commit karabiner-format check (scoped to top-level *.json) never sees it. --- .config/karabiner/.gitignore | 2 + .config/karabiner/.prettierrc | 2 +- .../karabiner/generator/karabiner-config.ts | 376 ++++++++++++++++++ .config/karabiner/generator/lib/verify.ts | 42 ++ .config/karabiner/generator/lib/write.ts | 86 ++++ .config/karabiner/generator/package.json | 17 + .config/karabiner/karabiner.json | 38 +- .../karabiner/scripts/toggle-input-source.sh | 8 + 8 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 .config/karabiner/.gitignore create mode 100644 .config/karabiner/generator/karabiner-config.ts create mode 100644 .config/karabiner/generator/lib/verify.ts create mode 100644 .config/karabiner/generator/lib/write.ts create mode 100644 .config/karabiner/generator/package.json create mode 100755 .config/karabiner/scripts/toggle-input-source.sh diff --git a/.config/karabiner/.gitignore b/.config/karabiner/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/.config/karabiner/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/.config/karabiner/.prettierrc b/.config/karabiner/.prettierrc index 50ba16a..2efe6dd 100644 --- a/.config/karabiner/.prettierrc +++ b/.config/karabiner/.prettierrc @@ -2,7 +2,7 @@ "plugins": ["./prettier-plugin-karabiner.mjs"], "overrides": [ { - "files": ["*.json", "assets/**/*.json"], + "files": ["karabiner.json", "assets/**/*.json"], "options": { "parser": "karabiner-json" } } ] diff --git a/.config/karabiner/generator/karabiner-config.ts b/.config/karabiner/generator/karabiner-config.ts new file mode 100644 index 0000000..35a1538 --- /dev/null +++ b/.config/karabiner/generator/karabiner-config.ts @@ -0,0 +1,376 @@ +// Karabiner-Elements configuration — source of truth for the +// "Default profile" complex_modifications rules. +// +// npm run build generate ~/.config/karabiner/karabiner.json +// npm run verify build + format + compare against live config (byte-exact) +// +// The generator output is normalized to the Karabiner-Elements GUI's +// canonical format (alphabetized keys) and formatted with the repo's +// prettier-plugin-karabiner (json_writer byte format), so a clean build +// produces zero diff against a GUI-maintained karabiner.json. +// +// Ported 1:1 from the hand-maintained karabiner.json, including: +// - all three disabled rules (control+tab, MS RDP, home-row mods) +// - the previously-uncommitted Ctrl+Space input-source rule +// and scripts/toggle-input-source.sh (now tracked) +// - the two hand-written rules re-keyed to the GUI's canonical order +// (content unchanged), so the whole file round-trips byte-identically +// +// Device IDs (from Karabiner-Elements event viewer): +// Adv360 Pro (BLE) vendor 7504 product 24926 +// Adv360 Pro (USB) vendor 10730 product 866 (wired QMK mode) +// gaming keyboard vendor 7847 product 2311 (COMPX 2.4G dongle) + +import { + ifApp, + ifDevice, + ifVar, + map, + mapConsumerKey, + mapSimultaneous, + rule, + withCondition, + withMapper, + type Rule, +} from 'karabiner.ts' + +// --------------------------------------------------------------------------- +// Devices +// --------------------------------------------------------------------------- + +const adv360Ble = { vendor_id: 7504, product_id: 24926 } +const adv360Usb = { vendor_id: 10730, product_id: 866 } +const gamingKeyboard = { vendor_id: 7847, product_id: 2311 } + +// Condition factories — the live config repeats these on every manipulator. +const zenApps = () => ifApp(['app.zen-browser.zen', /^com\.jgoon\.satori$/]) +const capsPressed = () => ifVar('caps_lock pressed', 1) +const vimMode = () => ifVar('vim_mode', 1) +const notAdv360 = () => ifDevice(adv360Ble).unless() +const notTerminalApps = () => + ifApp(['com.googlecode.iterm2', 'com.github.atom', 'com.jetbrains.pycharm']).unless() + +/** Live config uses side-specific left_* modifiers; keep them explicit. */ +const hyper4 = ['left_command', 'left_option', 'left_control', 'left_shift'] + +/** Add `enabled: false` / `title` to a built rule (karabiner.ts has no API). */ +function disable(built: Rule, title?: string): Rule { + return title ? { ...built, enabled: false, title } : { ...built, enabled: false } +} + +// --------------------------------------------------------------------------- +// Rule 1 — Zen Browser tab switching +// --------------------------------------------------------------------------- + +const zenRule = rule( + 'Zen Browser - Cmd+Option+Left/Right to previous/next tab (all keyboards, including QMK macro order)', +).manipulators([ + // QMK macros arrive as left_option+left_command+arrow; fired strictly in + // macro order with a 500ms window, on either Adv360 connection mode. + mapSimultaneous( + ['left_arrow', 'left_command', 'left_option'], + { detect_key_down_uninterruptedly: true, key_down_order: 'strict', key_up_when: 'all' }, + 500, + ) + .modifiers('optionalAny') + .to('open_bracket', ['command', 'shift']) + .condition(zenApps(), ifDevice([adv360Ble, adv360Usb])), + mapSimultaneous( + ['right_arrow', 'left_command', 'left_option'], + { detect_key_down_uninterruptedly: true, key_down_order: 'strict', key_up_when: 'all' }, + 500, + ) + .modifiers('optionalAny') + .to('close_bracket', ['command', 'shift']) + .condition(zenApps(), ifDevice([adv360Ble, adv360Usb])), + // Vanilla Cmd+Option+←/→ on every other keyboard. + map('left_arrow', ['command', 'option']) + .to('open_bracket', ['command', 'shift']) + .condition(zenApps()), + map('right_arrow', ['command', 'option']) + .to('close_bracket', ['command', 'shift']) + .condition(zenApps()), + // Mac keyboard, caps held (layer var from rule 4): ctrl+h / ctrl+l. + map('h', 'left_control') + .to('open_bracket', ['command', 'shift']) + .condition(zenApps(), capsPressed(), notAdv360()) + .description('Mac keyboard Caps+Control+H to previous Zen tab'), + map('l', 'left_control') + .to('close_bracket', ['command', 'shift']) + .condition(zenApps(), capsPressed(), notAdv360()) + .description('Mac keyboard Caps+Control+L to next Zen tab'), +]) + +// --------------------------------------------------------------------------- +// Rule 2 (disabled) — Control+Tab -> immediate Cmd+Tab +// --------------------------------------------------------------------------- + +const controlTabRule = disable( + rule('Control+Tab -> immediate Cmd+Tab on keydown') + .manipulators( + map('tab', 'control', 'any') + .to$( + 'osascript -e \'tell application "System Events" to key code 48 using {command down}\'', + ) + .condition(notAdv360()), + ) + .build(), +) + +// --------------------------------------------------------------------------- +// Rule 3 (disabled) — MS Remote Desktop: left cmd -> left ctrl +// --------------------------------------------------------------------------- + +const msrdRule = disable( + rule('MS Remote Desktop - Left cmd to Left ctrl') + .manipulators( + map({ key_code: 'left_gui' }) + .to('left_control', undefined, { repeat: true }) + .condition(ifApp('com.microsoft.rdc.macos'), notAdv360()), + ) + .build(), + 'MS Remote Desktop', +) + +// --------------------------------------------------------------------------- +// Rule 4 — caps layer: ESC alone, hjkl arrows, du page keys, fg hyper +// --------------------------------------------------------------------------- + +const capsNavRule = rule( + 'CAPS > ESC, CAPS+H/J/K/L > ←↓↑→, CAPS+D/U > PG↓↑', +).manipulators([ + // The descriptions in live are legacy labels from when these were + // control-chords; behavior kept verbatim from the original config. + map('h', 'left_control') + .to('left_arrow', ['left_command', 'left_option']) + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + h for moving to right tab'), + map('l', 'left_control') + .to('right_arrow', ['left_command', 'left_option']) + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + l for moving to left tab'), + map('k', 'left_control') + .to('w', 'left_command') + .condition(capsPressed(), notAdv360()) + .description('caps + left_control + k for closing tabs'), + map('j', 'optionalAny').to('down_arrow').condition(capsPressed(), notAdv360()), + map('k', 'optionalAny').to('up_arrow').condition(capsPressed(), notAdv360()), + map('h', 'optionalAny').to('left_arrow').condition(capsPressed(), notAdv360()), + map('l', 'optionalAny').to('right_arrow').condition(capsPressed(), notAdv360()), + map('d', 'optionalAny').to('page_down').condition(capsPressed(), notAdv360()), + map('u', 'optionalAny').to('page_up').condition(capsPressed(), notAdv360()), + // Layer toggle: press = var on, release = var off, alone = ESC. + map('caps_lock', 'optionalAny') + .toVar('caps_lock pressed', 1) + .toAfterKeyUp({ set_variable: { name: 'caps_lock pressed', value: 0 } }) + .toIfAlone('escape') + .condition(notAdv360()), + map('f', 'optionalAny') + .to('f', hyper4) + .condition(capsPressed(), notAdv360()) + .description('caps + f -> cmd+opt+ctrl+shift+f'), + map('g', 'optionalAny') + .to('g', hyper4) + .condition(capsPressed(), notAdv360()) + .description('caps + g -> cmd+opt+ctrl+shift+g'), +]) + +// --------------------------------------------------------------------------- +// Rule 5 — caps+A / caps+1..0 -> ctrl+A / ctrl+1..0 +// --------------------------------------------------------------------------- + +const capsWorkflowRule = rule( + 'CAPS+A / CAPS+1..0 > CTRL+A / CTRL+1..0 (workflow)', +).manipulators( + withCondition(capsPressed(), notAdv360())( + withMapper(['a', ...'1234567890'.split('')])((key) => + map(key) + .to(key, 'left_control') + .description(`caps + ${key} -> control + ${key}`), + ), + ), +) + +// --------------------------------------------------------------------------- +// Rule 6 — vim mode toggle (caps_lock) +// --------------------------------------------------------------------------- + +/** Turn vim_mode off and clear its notification. */ +const vimOff = () => [ + { set_variable: { name: 'vim_mode', value: 0 } }, + { set_notification_message: { id: 'vim_mode_plus_enabled', text: '' } }, +] + +const vimToggleRule = rule( + '(Vim 1/11) caps_lock -> on, caps_lock, esc, control+[ or any pointing_button -> off', +).manipulators([ + // Hold caps (>=100ms) or tap it: both arm vim_mode. Key-up disarms. + map('caps_lock') + .toIfAlone({ set_variable: { name: 'vim_mode', value: 1 } }) + .toIfHeldDown({ set_variable: { name: 'vim_mode', value: 1 } }) + .toAfterKeyUp(vimOff()) + .parameters({ + 'basic.to_if_alone_threshold_milliseconds': 0, + 'basic.to_if_held_down_threshold_milliseconds': 100, + }) + .condition(notTerminalApps(), ifVar('vim_mode').unless(), notAdv360()), + map('caps_lock') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + map('escape') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + map('open_bracket', 'control') + .to(vimOff()) + .condition(notTerminalApps(), vimMode(), notAdv360()), + // Inside terminal/editor apps the mode is meaningless: any key exits it. + map({ any: 'key_code' }) + .to(vimOff()) + .condition( + ifApp(['com.googlecode.iterm2', 'com.github.atom', 'com.jetbrains.pycharm']), + vimMode(), + notAdv360(), + ), + // Any mouse button exits, on any device. + map({ any: 'pointing_button' }) + .to(vimOff()) + .condition(vimMode(), notAdv360()), +]) + +// --------------------------------------------------------------------------- +// Rule 7 — vim navigation while vim_mode == 1 +// --------------------------------------------------------------------------- + +/** Shared conditions for every vim navigation manipulator. */ +const vimNavConds = () => [notTerminalApps(), vimMode(), notAdv360()] + +const vimNavRule = rule( + '(Vim 8/11) h,j,k,l (+ control/option/command/shift),e,b,0,^,$,gg,G,{,}', +).manipulators([ + // hjkl + u/d as arrows / page keys (any optional modifiers). + ...withCondition(...vimNavConds())([ + map('h', { optional: ['control', 'option', 'command', 'shift'] }).to('left_arrow'), + map('j', { optional: ['control', 'option', 'command', 'shift'] }).to('down_arrow'), + map('k', { optional: ['control', 'option', 'command', 'shift'] }).to('up_arrow'), + map('l', { optional: ['control', 'option', 'command', 'shift'] }).to('right_arrow'), + map('u', { optional: ['control', 'option', 'command', 'shift'] }).to('page_up'), + map('d', { optional: ['control', 'option', 'command', 'shift'] }).to('page_down'), + // Word-wise motion. + map('e').to('right_arrow', 'left_alt'), + map('b').to('left_arrow', 'left_alt'), + map('w').to('right_arrow', 'left_alt'), + // 0 -> cmd+left twice (line start); 6^/4$ via shifted digits. + map('0').to('left_arrow', 'left_command').to('left_arrow', 'left_command'), + map('6', 'shift').to('left_arrow', 'left_command'), + map('4', 'shift').to('right_arrow', 'left_command'), + ]), + // gg: first g sets a var; delayed action (500ms) clears it. Second g + // while set jumps to top. These two carry g_pressed in the middle of + // their condition list (matching the original rule's order). + map('g') + .toVar('g_pressed', 1) + .toDelayedAction([{ set_variable: { name: 'g_pressed', value: 0 } }], []) + .parameters({ 'basic.to_delayed_action_delay_milliseconds': 500 }) + .condition(notTerminalApps(), vimMode(), ifVar('g_pressed').unless(), notAdv360()), + map('g') + .to('up_arrow', 'left_command') + .toVar('g_pressed', 0) + .condition(notTerminalApps(), vimMode(), ifVar('g_pressed'), notAdv360()), + ...withCondition(...vimNavConds())([ + map('g', 'shift').to('down_arrow', 'left_command'), + // {[ ]} -> ctrl+a / ctrl+e (bracketed paragraph jumps). + map('open_bracket', 'shift').to('a', 'left_control'), + map('close_bracket', 'shift').to('e', 'left_control'), + ]), +]) + +// --------------------------------------------------------------------------- +// Rule 8 (disabled) — home-row mods +// --------------------------------------------------------------------------- + +const homeRowModsRule = disable( + rule('Home Row Mods: ASDF -> GACS, JKL; -> SCAG') + .manipulators( + withMapper({ + a: 'left_command', + s: 'left_option', + d: 'left_control', + f: 'left_shift', + j: 'right_shift', + k: 'right_control', + l: 'right_option', + semicolon: 'right_command', + })((key, mod) => + map(key, 'optionalAny') + .toIfAlone({ halt: true, key_code: key }) + .toIfHeldDown(mod) + .toDelayedAction([], [{ key_code: key }]) + .parameters({ + 'basic.to_delayed_action_delay_milliseconds': 150, + 'basic.to_if_held_down_threshold_milliseconds': 150, + }) + .condition(notAdv360()), + ), + ) + .build(), +) + +// --------------------------------------------------------------------------- +// Rule 9 — EarPods center button +// --------------------------------------------------------------------------- + +const earpodsRule = rule( + 'EarPods: tap = toggle Handy dictation, hold ≥250ms = play/pause music (fires mid-press)', +).manipulators( + mapConsumerKey('play_or_pause') + .toAfterKeyUp({ + shell_command: '/Users/jgoon/.config/karabiner/scripts/earpods-release.sh', + }) + .toIfHeldDown({ + shell_command: '/Users/jgoon/.config/karabiner/scripts/earpods-music-toggle.sh', + }) + .parameters({ 'basic.to_if_held_down_threshold_milliseconds': 250 }), +) + +// --------------------------------------------------------------------------- +// Rule 10 — Ctrl+Space input source toggle +// --------------------------------------------------------------------------- + +const inputSourceRule = rule( + 'Ctrl+Space toggles EN/Chinese via macism on all keyboards EXCEPT gaming keyboard; never in Roblox', +).manipulators( + map('spacebar', 'control') + .to$('$HOME/.config/karabiner/scripts/toggle-input-source.sh') + .condition( + ifApp(/^com\.roblox\./).unless(), + ifDevice([gamingKeyboard, adv360Usb]).unless(), + ), +) + +// --------------------------------------------------------------------------- +// Assemble + write +// --------------------------------------------------------------------------- + +const rules = [ + zenRule, + controlTabRule, + msrdRule, + capsNavRule, + capsWorkflowRule, + // NOTE: live order puts the nav rule (Vim 8/11) before the toggle rule + // (Vim 1/11) — later rules take precedence, and the toggle's `any key` + // exit-off manipulator must not shadow navigation keys. + vimNavRule, + vimToggleRule, + homeRowModsRule, + earpodsRule, + inputSourceRule, +] + +export { rules } + +// When run directly (npm run build / verify), write the profile. +if (import.meta.url === `file://${process.argv[1]}`) { + const { writeProfile } = await import('./lib/write.ts') + writeProfile(rules) +} diff --git a/.config/karabiner/generator/lib/verify.ts b/.config/karabiner/generator/lib/verify.ts new file mode 100644 index 0000000..1e64494 --- /dev/null +++ b/.config/karabiner/generator/lib/verify.ts @@ -0,0 +1,42 @@ +// lib/verify.ts — prove the generator reproduces the live karabiner.json +// byte-for-byte. +// +// npm run verify (from .config/karabiner/generator) +// +// Reads the live config, swaps in the generated complex_modifications +// (exactly like writeProfile does), canonicalizes + formats through the +// same pipeline, and compares to the file on disk. Exits non-zero and +// writes the expected bytes to /tmp on divergence. + +import { readFileSync, writeFileSync } from 'node:fs' +import { canonicalize, format, KARABINER_JSON } from './write.ts' + +async function main() { + const live = readFileSync(KARABINER_JSON, 'utf8') + const expected = await renderExpected(live) + if (expected === live) { + console.log('✓ Generator output is byte-identical to live karabiner.json') + return + } + console.error('✗ Generator output diverges from live karabiner.json') + console.error(` generated: ${expected.length} bytes, live: ${live.length} bytes`) + writeFileSync('/tmp/karabiner-generated-expected.json', expected) + console.error(' expected bytes written to /tmp/karabiner-generated-expected.json') + process.exitCode = 1 +} + +/** Build the expected bytes for a given live-config body. */ +async function renderExpected(live: string) { + const { complexModifications } = await import('karabiner.ts') + const { rules } = await import('../karabiner-config.ts') + const existing = JSON.parse(live) + const cm = complexModifications(rules) + const profile = existing.profiles.find( + (p: { name: string }) => p.name === 'Default profile', + ) + if (!profile) throw new Error('Profile "Default profile" not found') + profile.complex_modifications = { rules: cm.rules } + return format(canonicalize(existing)) +} + +main() diff --git a/.config/karabiner/generator/lib/write.ts b/.config/karabiner/generator/lib/write.ts new file mode 100644 index 0000000..40edf29 --- /dev/null +++ b/.config/karabiner/generator/lib/write.ts @@ -0,0 +1,86 @@ +// lib/write.ts — merge generated rules into the live karabiner.json. +// +// karabiner.ts's writeToProfile() replaces complex_modifications wholesale +// and injects five default parameters the live config doesn't have, so we +// do the merge ourselves with complexModifications() + a strict writer: +// +// - profile "Default profile" complex_modifications is replaced by the +// generated rules (no parameters object, matching the live file) +// - everything else (global, devices, virtual_hid_keyboard, …) is +// preserved verbatim from the existing file +// - output is canonicalized to GUI key order (alphabetical, like +// nlohmann::json's std::map) and formatted byte-identically to +// Karabiner-Elements' json_writer via prettier + prettier-plugin-karabiner + +import { complexModifications } from 'karabiner.ts' +import prettier from 'prettier' +import { readFileSync, writeFileSync } from 'node:fs' + +export const KARABINER_JSON = + process.env.KARABINER_JSON ?? `${process.env.HOME}/.config/karabiner/karabiner.json` + +export { canonicalize, format } + +/** + * Sort keys recursively, matching Karabiner-Elements' GUI writer output. + * `global` is exempt: the GUI (and writeToGlobal) preserve declaration + * order there, e.g. show_in_menu_bar before enable_cgeventtap_fallback. + * Also drops empty `to_if_invoked`/`to_if_canceled` arrays that + * karabiner.ts emits but json_writer never writes. + */ +function canonicalize(value: unknown, isGlobal = false): unknown { + if (Array.isArray(value)) return value.map((v) => canonicalize(v)) + if (typeof value === 'object' && value !== null) { + const out: Record = {} + const keys = Object.keys(value as Record) + for (const key of isGlobal ? keys : keys.sort()) { + const v = (value as Record)[key] + const childIsGlobal = key === 'global' && !isGlobal + out[key] = canonicalize(v, childIsGlobal) + } + // Prune empty delayed-action branches (json_writer omits them). + if ('to_delayed_action' in out) { + const da = out.to_delayed_action as Record + for (const branch of ['to_if_invoked', 'to_if_canceled']) { + if (Array.isArray(da[branch]) && da[branch].length === 0) delete da[branch] + } + if (Object.keys(da).length === 0) delete out.to_delayed_action + } + return out + } + return value +} + +/** Format with the repo's karabiner prettier plugin (json_writer bytes). */ +async function format(json: unknown): Promise { + const text = JSON.stringify(json) + const out = await prettier.format(text, { + parser: 'karabiner-json', + plugins: [prettierPlugin()], + }) + // Karabiner's json_writer never emits a trailing newline. + return out.replace(/\n$/, '') +} + +function prettierPlugin() { + // The repo-local plugin, one directory above the generator project. + const url = new URL('../../prettier-plugin-karabiner.mjs', import.meta.url) + return import(url.href) +} + +export async function writeProfile(rules: unknown[]) { + const cm = complexModifications(rules as never) + // Live config omits a profile-level parameters object; complexModifications + // always injects five defaults, so strip them back out for byte parity. + const generated = { rules: cm.rules } + const existing = JSON.parse(readFileSync(KARABINER_JSON, 'utf8')) + const profile = existing.profiles.find( + (p: { name: string }) => p.name === 'Default profile', + ) + if (!profile) throw new Error('Profile "Default profile" not found') + profile.complex_modifications = generated + + const formatted = await format(canonicalize(existing)) + writeFileSync(KARABINER_JSON, formatted) + console.log(`✓ Wrote ${cm.rules.length} rules to ${KARABINER_JSON}`) +} diff --git a/.config/karabiner/generator/package.json b/.config/karabiner/generator/package.json new file mode 100644 index 0000000..bcab45b --- /dev/null +++ b/.config/karabiner/generator/package.json @@ -0,0 +1,17 @@ +{ + "name": "karabiner-config", + "version": "1.0.0", + "description": "Karabiner-Elements config generated by karabiner.ts", + "type": "module", + "dependencies": { + "karabiner.ts": "^1.38.0" + }, + "devDependencies": { + "prettier": "^3.9.9", + "@types/node": "^22" + }, + "scripts": { + "build": "node karabiner-config.ts", + "verify": "node lib/verify.ts" + } +} diff --git a/.config/karabiner/karabiner.json b/.config/karabiner/karabiner.json index 95d7059..1d88a90 100644 --- a/.config/karabiner/karabiner.json +++ b/.config/karabiner/karabiner.json @@ -2059,11 +2059,45 @@ "description": "EarPods: tap = toggle Handy dictation, hold ≥250ms = play/pause music (fires mid-press)", "manipulators": [ { - "type": "basic", "from": { "consumer_key_code": "play_or_pause" }, + "parameters": { "basic.to_if_held_down_threshold_milliseconds": 250 }, "to_after_key_up": [{ "shell_command": "/Users/jgoon/.config/karabiner/scripts/earpods-release.sh" }], "to_if_held_down": [{ "shell_command": "/Users/jgoon/.config/karabiner/scripts/earpods-music-toggle.sh" }], - "parameters": { "basic.to_if_held_down_threshold_milliseconds": 250 } + "type": "basic" + } + ] + }, + { + "description": "Ctrl+Space toggles EN/Chinese via macism on all keyboards EXCEPT gaming keyboard; never in Roblox", + "manipulators": [ + { + "conditions": [ + { + "bundle_identifiers": [ + "^com\\.roblox\\." + ], + "type": "frontmost_application_unless" + }, + { + "identifiers": [ + { + "product_id": 2311, + "vendor_id": 7847 + }, + { + "product_id": 866, + "vendor_id": 10730 + } + ], + "type": "device_unless" + } + ], + "from": { + "key_code": "spacebar", + "modifiers": { "mandatory": ["control"] } + }, + "to": [{ "shell_command": "$HOME/.config/karabiner/scripts/toggle-input-source.sh" }], + "type": "basic" } ] } diff --git a/.config/karabiner/scripts/toggle-input-source.sh b/.config/karabiner/scripts/toggle-input-source.sh new file mode 100755 index 0000000..5ca18f7 --- /dev/null +++ b/.config/karabiner/scripts/toggle-input-source.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Toggle input source US <-> Simplified Chinese (ITABC), used by Karabiner Ctrl+Space rule +cur=$(/opt/homebrew/bin/macism) +if [ "$cur" = "com.apple.keylayout.US" ]; then + /opt/homebrew/bin/macism com.apple.inputmethod.SCIM.ITABC +else + /opt/homebrew/bin/macism com.apple.keylayout.US +fi From 7f42cc8f8e4fa476a53b27cd1fbfa158b95688ba Mon Sep 17 00:00:00 2001 From: Joshua Goon <25201575+Jish2@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:08:41 -0700 Subject: [PATCH 2/4] karabiner: add AGENTS.md docs for the generator setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two docs so any agent asked to "edit my Karabiner config" lands on the generator instead of hand-editing the JSON: - root AGENTS.md — repo guidance index; the Karabiner pointer fires on any request touching key remaps or ~/.config/karabiner/ - .config/karabiner/AGENTS.md — the actual workflow: edit generator/karabiner-config.ts, then build + verify (verify passing is the completion criterion), the byte-exactness link to the pre-commit hook, recovery for hand-edited JSON, and the load- bearing rule order (vim nav before toggle). Without these the obvious-but-wrong path is editing karabiner.json directly; npm run build would silently discard that work. --- .config/karabiner/AGENTS.md | 35 +++++++++++++++++++++++++++++++++++ AGENTS.md | 7 +++++++ 2 files changed, 42 insertions(+) create mode 100644 .config/karabiner/AGENTS.md create mode 100644 AGENTS.md diff --git a/.config/karabiner/AGENTS.md b/.config/karabiner/AGENTS.md new file mode 100644 index 0000000..d978cce --- /dev/null +++ b/.config/karabiner/AGENTS.md @@ -0,0 +1,35 @@ +# Karabiner config is generated — never hand-edit karabiner.json + +`karabiner.json` is build output. The source of truth is `generator/karabiner-config.ts` +(karabiner.ts DSL). Any key remap, rule, or condition change goes there — a hand edit +to the JSON is silently lost the next time anyone runs the build. + +## Workflow + +From this directory: + +1. Edit `generator/karabiner-config.ts` +2. `cd generator && npm ci` — first time only (node_modules is not tracked) +3. `npm run build` — regenerates `karabiner.json`; Karabiner-Elements hot-reloads it +4. `npm run verify` — asserts the generator reproduces the live file byte-for-byte + +Done means `npm run verify` prints `✓ Generator output is byte-identical to live karabiner.json`. +The byte-exactness matters: the pre-commit hook rejects karabiner.json in any other format, +so a failing verify (or a hand-edited JSON) blocks the next commit. + +## If karabiner.json changed by hand + +The Karabiner GUI or a rogue agent may have edited the JSON directly. Recovery: + +- diff the change against `git show HEAD:.config/karabiner/karabiner.json` +- port the semantic change into `generator/karabiner-config.ts` +- `npm run build && npm run verify` until verify passes + +## Gotchas + +- `npm run build` writes to `$HOME/.config/karabiner/karabiner.json` (the live file), + not the nearest `karabiner.json` — in a yadm worktree those are different files. +- Rule order is load-bearing: later rules take precedence. The vim nav rule (8/11) + must stay before the toggle rule (1/11) so the toggle's any-key exit doesn't + shadow navigation keys. +- Disabled rules (`enabled: false`) are intentional history — port them, don't drop them. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b1465c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repo guidance index + +Pointers to per-area agent docs — reach the one matching the work: + +- **Karabiner**: any request to edit key remaps, rules, or `~/.config/karabiner/` — + read [`.config/karabiner/AGENTS.md`](.config/karabiner/AGENTS.md) first. + The config is generated from TypeScript; hand-editing the JSON loses work. From 1ccada2bc8abbef9f548876193062e2509cfae24 Mon Sep 17 00:00:00 2001 From: Joshua Goon <25201575+Jish2@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:31:10 -0700 Subject: [PATCH 3/4] karabiner: drop duplicate Ctrl+Space rule from merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of main into this branch kept both copies of the Ctrl+Space input-source rule — the generator's canonical-keyed one and main's hand-written one — leaving two semantically identical rules in karabiner.json. Karabiner matches the first, so behavior was unchanged, but npm run verify (and the build-must-reproduce-live invariant) caught the drift. Keep the canonical-ordered copy; dedupe restores verify to byte-identical. --- .config/karabiner/karabiner.json | 34 -------------------------------- 1 file changed, 34 deletions(-) diff --git a/.config/karabiner/karabiner.json b/.config/karabiner/karabiner.json index a6bad35..1d88a90 100644 --- a/.config/karabiner/karabiner.json +++ b/.config/karabiner/karabiner.json @@ -2100,40 +2100,6 @@ "type": "basic" } ] - }, - { - "description": "Ctrl+Space toggles EN/Chinese via macism on all keyboards EXCEPT gaming keyboard; never in Roblox", - "manipulators": [ - { - "type": "basic", - "from": { - "key_code": "spacebar", - "modifiers": { "mandatory": ["control"] } - }, - "conditions": [ - { - "type": "frontmost_application_unless", - "bundle_identifiers": [ - "^com\\.roblox\\." - ] - }, - { - "type": "device_unless", - "identifiers": [ - { - "vendor_id": 7847, - "product_id": 2311 - }, - { - "vendor_id": 10730, - "product_id": 866 - } - ] - } - ], - "to": [{ "shell_command": "$HOME/.config/karabiner/scripts/toggle-input-source.sh" }] - } - ] } ] }, From 82f3a34b2f54df434a72b6482a255d65bf46bc50 Mon Sep 17 00:00:00 2001 From: Joshua Goon <25201575+Jish2@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:07:53 -0700 Subject: [PATCH 4/4] karabiner: replace AGENTS.md docs with a karabiner skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator-setup docs lived in two AGENTS.md files (repo root + .config/karabiner/). Root AGENTS.md is machine-global context loaded by every session under $HOME — too broad a place for one tool's workflow — and a subdirectory AGENTS.md is only auto-loaded when a session starts inside that directory, so an agent started elsewhere (and given a file path) could still miss it. A model-invoked skill fires on any karabiner mention from anywhere: one always-loaded description line instead of two always-loaded docs, and the full workflow loads only when the skill triggers. . agents/skills/karabiner/SKILL.md carries the workflow: edit generator/karabiner-config.ts, build, verify (verify passing is the completion criterion), the hand-edited-JSON recovery path, and the load-bearing gotchas (rule order, disabled rules, $HOME write target, device IDs). --- .agents/skills/karabiner/SKILL.md | 54 +++++++++++++++++++++++++++++++ .config/karabiner/AGENTS.md | 35 -------------------- AGENTS.md | 7 ---- 3 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 .agents/skills/karabiner/SKILL.md delete mode 100644 .config/karabiner/AGENTS.md delete mode 100644 AGENTS.md diff --git a/.agents/skills/karabiner/SKILL.md b/.agents/skills/karabiner/SKILL.md new file mode 100644 index 0000000..42bffc5 --- /dev/null +++ b/.agents/skills/karabiner/SKILL.md @@ -0,0 +1,54 @@ +--- +name: karabiner +description: Edit or inspect this machine's Karabiner-Elements config (~/.config/karabiner/). Use whenever the user mentions Karabiner, a key remap, caps-lock/layer behavior, a keyboard shortcut change, or files like karabiner.json — the config is generated from TypeScript; editing the JSON directly loses work on the next build. +--- + +# Karabiner config is generated + +`~/.config/karabiner/karabiner.json` is build output. The source of truth is +`~/.config/karabiner/generator/karabiner-config.ts` (karabiner.ts DSL). Any +remap, rule, condition, or threshold change goes there. The Karabiner GUI may +rewrite the JSON — that is expected; hand edits are what get lost. + +## Workflow + +1. Edit `generator/karabiner-config.ts`. The file is commented rule-by-rule; + match the existing style (`map()`, `rule()`, condition factories like + `capsPressed()`). +2. `cd ~/.config/karabiner/generator && npm ci` — first time only on a + machine (node_modules is not tracked). +3. `npm run build` — regenerates the JSON and writes it to the **live** + `~/.config/karabiner/karabiner.json` ($HOME, never the nearest copy — in + a yadm worktree those are different files). Karabiner-Elements watches + the file and hot-reloads. +4. `npm run verify` — asserts the generator reproduces the live JSON + byte-for-byte. + +Done means verify prints `✓ Generator output is byte-identical to live +karabiner.json`. Byte-exactness matters: the df repo's pre-commit hook +rejects karabiner.json in any other format, so a failing verify blocks the +next commit. + +## If karabiner.json was hand-edited + +The GUI or another agent may have changed the JSON. Recovery: + +1. `git -C ~/.config/karabiner diff` or compare against + `git show HEAD:.config/karabiner/karabiner.json` (run from the yadm + checkout / worktree that owns the file) +2. Port the semantic change into `generator/karabiner-config.ts` +3. `npm run build && npm run verify` until verify passes + +## Gotchas + +- Rule order is load-bearing: later rules take precedence. The vim nav rule + (8/11) must stay before the toggle rule (1/11) so the toggle's any-key + exit doesn't shadow navigation keys. +- Disabled rules (`enabled: false`) are intentional history — port them, + don't drop them. +- Build reads the live JSON to preserve everything outside + `complex_modifications` (devices, virtual_hid_keyboard, global). If a + rule needs adding while the live file is stale, fix the live file first + via the recovery path above. +- Devices: Adv360 Pro BLE = vendor 7504 / product 24926; its USB mode = + 10730/866; gaming keyboard = 7847/2311. diff --git a/.config/karabiner/AGENTS.md b/.config/karabiner/AGENTS.md deleted file mode 100644 index d978cce..0000000 --- a/.config/karabiner/AGENTS.md +++ /dev/null @@ -1,35 +0,0 @@ -# Karabiner config is generated — never hand-edit karabiner.json - -`karabiner.json` is build output. The source of truth is `generator/karabiner-config.ts` -(karabiner.ts DSL). Any key remap, rule, or condition change goes there — a hand edit -to the JSON is silently lost the next time anyone runs the build. - -## Workflow - -From this directory: - -1. Edit `generator/karabiner-config.ts` -2. `cd generator && npm ci` — first time only (node_modules is not tracked) -3. `npm run build` — regenerates `karabiner.json`; Karabiner-Elements hot-reloads it -4. `npm run verify` — asserts the generator reproduces the live file byte-for-byte - -Done means `npm run verify` prints `✓ Generator output is byte-identical to live karabiner.json`. -The byte-exactness matters: the pre-commit hook rejects karabiner.json in any other format, -so a failing verify (or a hand-edited JSON) blocks the next commit. - -## If karabiner.json changed by hand - -The Karabiner GUI or a rogue agent may have edited the JSON directly. Recovery: - -- diff the change against `git show HEAD:.config/karabiner/karabiner.json` -- port the semantic change into `generator/karabiner-config.ts` -- `npm run build && npm run verify` until verify passes - -## Gotchas - -- `npm run build` writes to `$HOME/.config/karabiner/karabiner.json` (the live file), - not the nearest `karabiner.json` — in a yadm worktree those are different files. -- Rule order is load-bearing: later rules take precedence. The vim nav rule (8/11) - must stay before the toggle rule (1/11) so the toggle's any-key exit doesn't - shadow navigation keys. -- Disabled rules (`enabled: false`) are intentional history — port them, don't drop them. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 8b1465c..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Repo guidance index - -Pointers to per-area agent docs — reach the one matching the work: - -- **Karabiner**: any request to edit key remaps, rules, or `~/.config/karabiner/` — - read [`.config/karabiner/AGENTS.md`](.config/karabiner/AGENTS.md) first. - The config is generated from TypeScript; hand-editing the JSON loses work.