Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Created May 27, 2026 17:54
Show Gist options
  • Select an option

  • Save johnlindquist/3964f7b04f11c4b11b622c8ee2853b8c to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/3964f7b04f11c4b11b622c8ee2853b8c to your computer and use it in GitHub Desktop.
kar: Write Your Karabiner Config in TypeScript — tutorial + config by @johnlindquist

kar: Write Your Karabiner Config in TypeScript

kar lets you write your Karabiner-Elements keyboard config in TypeScript instead of editing raw JSON. It compiles a config.ts into Karabiner's complex modifications format and writes it directly into your karabiner.json.

This gist walks through setup, explains the key concepts, and includes my full config as a working example you can copy and customize.


Prerequisites

  • macOS with Karabiner-Elements installed
  • Deno or Bun (kar uses one of these to evaluate your TypeScript)
# Install Deno (recommended)
curl -fsSL https://deno.land/install.sh | sh

# Or install Bun
curl -fsSL https://bun.sh/install | bash

Install kar

# Clone and build with Cargo
git clone https://github.com/nikivdev/kar.git
cd kar
cargo build --release

# Copy the binary somewhere on your PATH
cp target/release/kar /usr/local/bin/

Initialize Your Config

kar init

This creates ~/.config/kar/config.ts with a starter template. Open it in your editor and start customizing.

Build and Apply

# Build once — writes to a "kar" profile in karabiner.json
kar

# Watch mode — rebuilds on every save
kar watch

# Dry run — prints generated JSON without writing
kar --dry-run

kar creates a separate Karabiner profile called kar and auto-selects it. Your original profile stays untouched.


Core Concepts

1. Simple Remaps

The most basic thing — remap one key to another with no conditions:

simple: [
  { from: "caps_lock", to: "escape" },
]

2. Simlayers (Simultaneous Layers)

This is kar's killer feature, inspired by Goku. A simlayer turns any key into a layer trigger when pressed simultaneously with another key. Tap the key normally and it types as usual.

// Define the layer
simlayers: {
  "s-mode": { key: "s" },
},

// Use it in rules
rules: [
  {
    description: "s-mode (navigation)",
    layer: "s-mode",
    mappings: [
      { from: "h", to: "left_arrow" },
      { from: "j", to: "down_arrow" },
      { from: "k", to: "up_arrow" },
      { from: "l", to: "right_arrow" },
    ],
  },
]

How it works: Press s + h together (within the simultaneous threshold) and you get left_arrow. Press s alone and you get a normal s.

3. Modifiers

Attach modifiers to any to key:

// Single modifier
{ from: "b", to: { key: "left_arrow", modifiers: "left_command" } }

// Multiple modifiers
{ from: "w", to: { key: "right_arrow", modifiers: ["left_option", "left_shift"] } }

4. Key Sequences (Multiple Actions)

Fire multiple keystrokes from one mapping:

// Select current word: Option+Left then Option+Shift+Right
{ from: "w", to: [
  { key: "left_arrow", modifiers: "option" },
  { key: "right_arrow", modifiers: ["option", "shift"] },
]}

5. Simultaneous Keys (No Layer)

Trigger an action by pressing two keys at the same time — no layer definition needed:

{ from: ["j", "k"], to: "escape" }

6. Shell Commands

Run any shell command from a key press:

import { shell, open, zed } from "kar/types"

{ from: "t", to: shell("open -a Terminal") }
{ from: "s", to: open("/Applications/Safari.app") }
{ from: "z", to: zed("~/projects/my-app") }

7. Conditions (App-Specific Rules)

Scope rules to specific apps:

{
  description: "Safari-only shortcuts",
  condition: { app: "^com\\.apple\\.Safari$" },
  mappings: [
    { from: "n", to: { key: "l", modifiers: "left_command" } },
  ],
}

8. Profile Timing

Fine-tune the feel of your layers:

profile: {
  alone: 80,    // ms before a held key fires to_if_alone (tap detection)
  sim: 200,     // ms window for simultaneous key detection
},

My Config

See config.ts in this gist for a complete, working config that demonstrates:

  • s-mode: Vim-style navigation (hjkl), word jumps, clipboard ops, tab switching, text selection — all from the home row
  • semicolon-mode: A full shift layer so you never need to reach for Shift
  • Simultaneous combos: j+k, k+n, j+l for app launching and system shortcuts
  • Colon/semicolon swap: Types : by default (useful for Vim users and programmers)

Quick Start with My Config

# 1. Install kar (see above)

# 2. Initialize
kar init

# 3. Replace the default config with mine
cp config.ts ~/.config/kar/config.ts

# 4. Build and apply
kar

# 5. (Optional) Watch for changes
kar watch

Customizing

The config is plain TypeScript — edit it however you want:

  • Change navigation keys: Swap hjkl for wasd or anything else
  • Add app launchers: Use shell("open -a AppName") or open("/path/to/app")
  • Add layers: Define a new simlayer (e.g., "d-mode": { key: "d" }) and add rules for it
  • Remove the semicolon swap: Delete that rule block if you prefer standard ; behavior
  • Remove Keyboard Maestro integration: The km() calls require Keyboard Maestro. Replace them with shell() commands or remove those mappings

Layer Cheatsheet

Layer Trigger What It Does
s-mode Hold s + key Navigation, editing, clipboard
semicolon-mode Hold ; + key Shift layer (capital letters, symbols)
Combo Keys Action
j+k Simultaneous Keyboard Maestro: open Safari tab
k+n Simultaneous Keyboard Maestro: open Comet tab
j+l Simultaneous Cmd+Shift+Space
k+l Simultaneous Cmd+Option+Ctrl+Space
j+; Simultaneous Cmd+Option+Shift+9

Tips

  • Start small. Add one simlayer, use it for a week, then expand.
  • Use kar --dry-run to inspect the generated JSON before applying.
  • Your original Karabiner profile is safe. kar writes to its own profile.
  • Layer keys still type normally when tapped alone — you don't lose any keys.

Migration from Goku

If you're coming from Goku (EDN-based Karabiner config), kar supports importing your existing rules:

import { importProfile } from "kar/types"

export default {
  imports: [
    importProfile("your-goku-profile"),
  ],
  // Then incrementally move rules to native kar format
} satisfies Config

Resources

import type { Config } from "kar/types"
import { km, shell, open } from "kar/types"
// ============================================================
// John Lindquist's kar config
//
// Simlayers: s-mode (navigation/editing), semicolon-mode (shift)
// Simultaneous combos: j+k, k+n, j+l, k+l, j+;
// Swap : and ; (colon is default, semicolon requires shift)
//
// Customize to taste — see the tutorial for details.
// ============================================================
export default {
profile: {
alone: 80,
sim: 200,
},
simlayers: {
"s-mode": { key: "s" },
"semicolon-mode": { key: "semicolon" },
},
rules: [
// ── S-MODE: Navigation, editing, clipboard ──────────────
{
description: "s-mode (essential)",
layer: "s-mode",
mappings: [
// Vim-style arrow keys
{ from: "h", to: "left_arrow" },
{ from: "j", to: "down_arrow" },
{ from: "k", to: "up_arrow" },
{ from: "l", to: "right_arrow" },
// Jump to beginning/end of line
{ from: "b", to: { key: "left_arrow", modifiers: "left_command" } },
{ from: "m", to: { key: "right_arrow", modifiers: "left_command" } },
// Editing
{ from: "d", to: "delete_or_backspace" },
{ from: "f", to: "return_or_enter" },
{ from: "g", to: { key: "tab", modifiers: "left_command" } },
// Clipboard
{ from: "a", to: { key: "c", modifiers: "left_command" } },
{ from: "n", to: { key: "v", modifiers: "left_command" } },
{ from: "o", to: { key: "x", modifiers: "left_command" } },
// Tab / Shift-Tab
{ from: "e", to: "tab" },
{ from: "r", to: { key: "tab", modifiers: "left_shift" } },
// Select current word
{ from: "w", to: [
{ key: "left_arrow", modifiers: "left_option" },
{ key: "right_arrow", modifiers: ["left_option", "left_shift"] },
]},
],
},
// ── SEMICOLON-MODE: Shift layer ─────────────────────────
// Hold semicolon + any key to get the shifted version.
// Useful if you prefer not reaching for the Shift key.
{
description: "semicolon-mode (shift)",
layer: "semicolon-mode",
mappings: [
// Letters
{ from: "q", to: { key: "q", modifiers: "left_shift" } },
{ from: "w", to: { key: "w", modifiers: "left_shift" } },
{ from: "e", to: { key: "e", modifiers: "left_shift" } },
{ from: "r", to: { key: "r", modifiers: "left_shift" } },
{ from: "t", to: { key: "t", modifiers: "left_shift" } },
{ from: "y", to: { key: "y", modifiers: "left_shift" } },
{ from: "u", to: { key: "u", modifiers: "left_shift" } },
{ from: "i", to: { key: "i", modifiers: "left_shift" } },
{ from: "o", to: { key: "o", modifiers: "left_shift" } },
{ from: "p", to: { key: "p", modifiers: "left_shift" } },
{ from: "a", to: { key: "a", modifiers: "left_shift" } },
{ from: "s", to: { key: "s", modifiers: "left_shift" } },
{ from: "d", to: { key: "d", modifiers: "left_shift" } },
{ from: "f", to: { key: "f", modifiers: "left_shift" } },
{ from: "g", to: { key: "g", modifiers: "left_shift" } },
{ from: "h", to: { key: "h", modifiers: "left_shift" } },
{ from: "j", to: { key: "j", modifiers: "left_shift" } },
{ from: "k", to: { key: "k", modifiers: "left_shift" } },
{ from: "l", to: { key: "l", modifiers: "left_shift" } },
{ from: "z", to: { key: "z", modifiers: "left_shift" } },
{ from: "x", to: { key: "x", modifiers: "left_shift" } },
{ from: "c", to: { key: "c", modifiers: "left_shift" } },
{ from: "v", to: { key: "v", modifiers: "left_shift" } },
{ from: "b", to: { key: "b", modifiers: "left_shift" } },
{ from: "n", to: { key: "n", modifiers: "left_shift" } },
{ from: "m", to: { key: "m", modifiers: "left_shift" } },
// Numbers → symbols
{ from: "1", to: { key: "1", modifiers: "left_shift" } },
{ from: "2", to: { key: "2", modifiers: "left_shift" } },
{ from: "3", to: { key: "3", modifiers: "left_shift" } },
{ from: "4", to: { key: "4", modifiers: "left_shift" } },
{ from: "5", to: { key: "5", modifiers: "left_shift" } },
{ from: "6", to: { key: "6", modifiers: "left_shift" } },
{ from: "7", to: { key: "7", modifiers: "left_shift" } },
{ from: "8", to: { key: "8", modifiers: "left_shift" } },
{ from: "9", to: { key: "9", modifiers: "left_shift" } },
],
},
// ── SIMULTANEOUS COMBOS ─────────────────────────────────
// Press two keys at the same time to trigger an action.
// Replace the km() calls with shell() or open() for your own apps.
{
description: "simultaneous keys",
mappings: [
// These use Keyboard Maestro — replace with your own actions:
// km("macro name") → triggers a Keyboard Maestro macro
// shell("command") → runs a shell command
// open("/path/app") → opens an app
{ from: ["j", "k"], to: km("open Safari new tab") },
{ from: ["k", "n"], to: km("open Comet new tab") },
// System shortcuts
{ from: ["j", "l"], to: { key: "spacebar", modifiers: ["left_command", "left_shift"] } },
{ from: ["k", "l"], to: { key: "spacebar", modifiers: ["left_command", "left_option", "left_control"] } },
{ from: ["j", "semicolon"], to: { key: "9", modifiers: ["left_command", "left_option", "left_shift"] } },
],
},
// ── COLON/SEMICOLON SWAP ────────────────────────────────
// Makes : the default (no shift needed) and ; requires shift.
// Remove this block if you prefer the standard layout.
{
description: "swap : and ;",
mappings: [
{ from: { key: "semicolon", modifiers: [] }, to: { key: "semicolon", modifiers: "left_shift" } },
{ from: { key: "semicolon", modifiers: "left_shift" }, to: "semicolon" },
],
},
],
} satisfies Config
// ============================================================
// Migration config — for users coming from Goku (EDN) configs.
//
// This wraps raw Karabiner JSON rules generated from a Goku
// baseline, with TypeScript patching functions on top.
// If you're starting fresh, use config.ts instead.
//
// Dependencies: this config imports from ./generated/ and
// ./types/ directories created by the migration scripts.
// See: https://github.com/nikivdev/kar
// ============================================================
import type { Config, RawKarabinerRule } from "./types/index.ts"
import { generatedProfile } from "./generated/profile.generated.ts"
import { generatedRawRules } from "./generated/raw-rules.generated.ts"
import { generatedRawSimpleModifications } from "./generated/raw-simple.generated.ts"
const CMUX_ZED_RULE_DESCRIPTION = "cmux: Cmd+Shift+Z -> Open Zed at CWD"
const COMMAND_MODE_RULE_DESCRIPTION = "Command Mode (C + Key)"
const CONTROL_MODE_RULE_DESCRIPTION = "Control Mode (C + Key)"
const CMUX_RELEASE_BUNDLE_ID = "com.cmuxterm.app"
const CMUX_DEV_BUNDLE_ID = "com.cmuxterm.app.debug.zed.open.test"
const CMUX_DEV_CLI =
"/path/to/your/cmux/binary"
const CMUX_DEV_SOCKET = "/tmp/cmux-debug-zed-open-test.sock"
const CMUX_DEV_SESSION =
"/path/to/your/cmux/session.json"
const CMUX_ZED_RECEIVER =
"/usr/bin/python3 /path/to/your/scripts/karabiner_zed_cmux_receiver.py --once"
const CMUX_ZED_DEV_RECEIVER = `${CMUX_ZED_RECEIVER} --cmux "${CMUX_DEV_CLI}" --socket "${CMUX_DEV_SOCKET}" --session "${CMUX_DEV_SESSION}"`
const PERIOD_MODE_RULE_DESCRIPTION = "Period Mode (. + Key) - Focus or Open URLs"
const FOCUS_TAB_SCRIPT_PATH = "/path/to/your/focus-tab.scpt"
const CHROME_APP_NAME = "Google Chrome"
function focusOrOpenTabCommand(url: string): string {
const escapedTarget = JSON.stringify(url)
const hasScheme = url.includes("://")
const fallbackUrl = hasScheme ? url : `https://${url}`
const escapedFallback = JSON.stringify(fallbackUrl)
const script = `
tell application "${CHROME_APP_NAME}"
set targetUrl to ${escapedTarget}
set fallbackUrl to ${escapedFallback}
set targetDomain to targetUrl
if targetUrl starts with "https://" then
set targetDomain to text 9 through -1 of targetUrl
else if targetUrl starts with "http://" then
set targetDomain to text 8 through -1 of targetUrl
end if
set foundWindow to missing value
set foundTabIndex to 1
repeat with wIndex from 1 to (count of windows)
set currentWindow to window wIndex
repeat with tIndex from 1 to (count tabs of currentWindow)
set tabUrl to URL of tab tIndex of currentWindow as string
if tabUrl contains targetDomain then
set foundWindow to wIndex
set foundTabIndex to tIndex
exit repeat
end if
end repeat
if foundWindow is not missing value then
exit repeat
end if
end repeat
if foundWindow is missing value then
if (count of windows) is 0 then
make new window
end if
tell front window
set newTab to make new tab with properties {URL: fallbackUrl}
set active tab index to (count of tabs)
end tell
else
tell window foundWindow
set active tab index to foundTabIndex
end tell
tell window foundWindow to activate
set index of window foundWindow to 1
end if
end tell`
return `/usr/bin/osascript -e ${JSON.stringify(script)}`
}
function patchFocusTabShellCommand(shellCommand: unknown): unknown {
if (typeof shellCommand !== "string" || !shellCommand.includes(FOCUS_TAB_SCRIPT_PATH)) {
return shellCommand
}
if (!shellCommand.startsWith(`/usr/bin/osascript ${FOCUS_TAB_SCRIPT_PATH} `)) {
return shellCommand
}
const [quotedUrl] = shellCommand.replace(`/usr/bin/osascript ${FOCUS_TAB_SCRIPT_PATH} `, "").split(" ")
const url = quotedUrl
? quotedUrl.replace(/^"(.*)"$/, "$1")
: ""
return focusOrOpenTabCommand(url)
}
function isHyperModifierList(modifiers: unknown): boolean {
if (!Array.isArray(modifiers)) {
return false
}
const sorted = [...modifiers].sort()
const hyper = ["command", "control", "option", "shift"].sort()
return (
sorted.length === hyper.length &&
sorted.every((modifier, index) => modifier === hyper[index])
)
}
function commandLayerModifier(modifier: string): string {
if (modifier === "left_command") {
return "left_control"
}
if (modifier === "right_command") {
return "right_control"
}
if (modifier === "command") {
return "control"
}
return modifier
}
function patchCommandModeModifiers(modifiers: unknown): unknown {
if (!Array.isArray(modifiers) || isHyperModifierList(modifiers)) {
return modifiers
}
return modifiers.map((modifier) =>
typeof modifier === "string" ? commandLayerModifier(modifier) : modifier,
)
}
function patchCommandModeActions(actions: unknown): unknown {
if (!Array.isArray(actions)) {
return actions
}
return actions.map((action) => {
if (!action || typeof action !== "object") {
return action
}
const record = action as Record<string, unknown>
if (!("modifiers" in record)) {
return action
}
return {
...record,
modifiers: patchCommandModeModifiers(record.modifiers),
}
})
}
function patchCommandModeLayer(rule: RawKarabinerRule): RawKarabinerRule {
if (rule.description !== COMMAND_MODE_RULE_DESCRIPTION) {
return rule
}
const manipulators = Array.isArray(rule.manipulators)
? rule.manipulators.map((manipulator) => {
if (!manipulator || typeof manipulator !== "object") {
return manipulator
}
const record = manipulator as Record<string, unknown>
return {
...record,
to: patchCommandModeActions(record.to),
}
})
: rule.manipulators
return {
...rule,
description: CONTROL_MODE_RULE_DESCRIPTION,
manipulators,
}
}
function patchFocusTabRule(rule: RawKarabinerRule): RawKarabinerRule {
if (rule.description !== PERIOD_MODE_RULE_DESCRIPTION) {
return rule
}
const manipulators = Array.isArray(rule.manipulators)
? rule.manipulators.map((manipulator) => {
if (!manipulator || typeof manipulator !== "object") {
return manipulator
}
const record = manipulator as Record<string, unknown>
if (!Array.isArray(record.to)) {
return manipulator
}
const patchedTo = record.to.map((action) => {
if (!action || typeof action !== "object") {
return action
}
const actionRecord = action as Record<string, unknown>
if (!("shell_command" in actionRecord)) {
return action
}
return {
...actionRecord,
shell_command: patchFocusTabShellCommand(actionRecord.shell_command),
}
})
return {
...record,
to: patchedTo,
}
})
: rule.manipulators
const enhancedManipulators = manipulators.map((manipulator) => {
if (!manipulator || typeof manipulator !== "object") {
return manipulator
}
const record = manipulator as Record<string, unknown>
const from = record.from
if (!from || typeof from !== "object") {
return manipulator
}
const fromRecord = from as Record<string, unknown>
const simultaneous = fromRecord.simultaneous
if (!Array.isArray(simultaneous) || simultaneous.length !== 2) {
return manipulator
}
const keyCodes = simultaneous.map((entry) => {
if (!entry || typeof entry !== "object") {
return ""
}
const keyCodeEntry = entry as Record<string, unknown>
return typeof keyCodeEntry.key_code === "string" ? keyCodeEntry.key_code : ""
})
if (!keyCodes.includes("period") || !keyCodes.includes("x")) {
return manipulator
}
const simultaneousOptions =
typeof fromRecord.simultaneous_options === "object" && fromRecord.simultaneous_options
? (fromRecord.simultaneous_options as Record<string, unknown>)
: {}
const patchedFromRecord = {
...fromRecord,
simultaneous_options: {
...simultaneousOptions,
key_down_order: "insensitive",
detect_key_down_uninterruptedly: false,
},
}
const parameters =
typeof record.parameters === "object" && record.parameters ? (record.parameters as Record<string, unknown>) : {}
return {
...record,
from: patchedFromRecord,
parameters: {
...parameters,
"basic.simultaneous_threshold_milliseconds": 220,
},
}
})
return {
...rule,
manipulators: enhancedManipulators,
}
}
function patchKarRules(rules: RawKarabinerRule[]): RawKarabinerRule[] {
return rules.map((rule) => {
const commandModeRule = patchCommandModeLayer(rule)
const focusTabRule = patchFocusTabRule(commandModeRule)
return targetDevCmuxZedShortcut([focusTabRule])[0] ?? focusTabRule
})
}
function targetDevCmuxZedShortcut(
rules: RawKarabinerRule[],
): RawKarabinerRule[] {
return rules.map((rule) => {
if (rule.description !== CMUX_ZED_RULE_DESCRIPTION) {
return rule
}
const manipulators = Array.isArray(rule.manipulators)
? rule.manipulators.flatMap((manipulator) => {
if (!manipulator || typeof manipulator !== "object") {
return [manipulator]
}
const record = manipulator as Record<string, unknown>
return [
{
...record,
to: [{ shell_command: CMUX_ZED_RECEIVER }],
conditions: patchCmuxZedConditions(
record.conditions,
CMUX_RELEASE_BUNDLE_ID,
),
},
{
...record,
to: [{ shell_command: CMUX_ZED_DEV_RECEIVER }],
conditions: patchCmuxZedConditions(
record.conditions,
CMUX_DEV_BUNDLE_ID,
),
},
]
})
: rule.manipulators
return {
...rule,
manipulators,
}
})
}
function patchCmuxZedConditions(
conditions: unknown,
bundleIdentifier: string,
): unknown {
if (!Array.isArray(conditions)) {
return conditions
}
return conditions.map((condition) => {
if (!condition || typeof condition !== "object") {
return condition
}
const record = condition as Record<string, unknown>
if (record.type !== "frontmost_application_if") {
return condition
}
return {
...record,
bundle_identifiers: [bundleIdentifier],
}
})
}
export default {
profile: generatedProfile,
rules: [],
raw_rules: patchKarRules(generatedRawRules),
raw_simple: generatedRawSimpleModifications,
} satisfies Config
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment