Skip to content
This repository was archived by the owner on Jul 3, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
pnpm-lock.yaml.json
*.log
57 changes: 57 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is a Karabiner Elements complex modification that implements Vim-style navigation and editing across macOS. The project creates a system-wide Vim mode that can be activated with caps lock, providing familiar hjkl navigation and other Vim commands in any application.

## Key Architecture

- **Source Configuration**: `vim_mode_plus.yml` - The main configuration file written in YAML for readability and maintainability
- **Generated Configuration**: `vim_mode_plus.json` - The actual Karabiner Elements configuration file generated from the YAML
- **Build Tool**: `yml-to-json.py` - Python script that converts YAML to JSON format required by Karabiner

## Development Workflow

### Building the Configuration
```bash
# Convert YAML to JSON (required after making changes)
python3 yml-to-json.py
```

### Testing Changes
After rebuilding:
1. Remove all parts of this mod in Karabiner's "Complex modifications" tab
2. Re-add all parts in the correct order (the order is important)
3. Test the vim mode functionality

## Configuration Structure

The YAML file contains 11 main rule groups that handle:
1. Mode activation/deactivation (caps lock, escape, etc.)
2. Basic navigation (hjkl, word movement, line movement)
3. Delete operations (d + navigation keys)
4. Yank operations (y + navigation keys)
5. Change operations (c + navigation keys)
6. Insert mode transitions (i, a, o, etc.)
7. Visual mode functionality
8. Undo/redo operations
9. Paste operations
10. Special key mappings (F18, F19, F20 for Hammerspoon integration)
11. Application-specific exceptions

## Key Implementation Details

- Uses Karabiner's variable system to track vim_mode state
- Excludes certain applications (iTerm2, Atom, PyCharm, VSCode) that have their own Vim modes
- Provides visual feedback through macOS notifications
- Supports both tap and hold behaviors for caps lock
- Integrates with Hammerspoon for additional modal functionality

## Development Notes

- Always edit the YAML file, never the JSON directly
- The 2485-line YAML file generates the complete Karabiner configuration
- Order of rules in Karabiner matters - they must be added in sequence
- Complex modifications use Karabiner's `manipulators` with conditions and variables
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,32 @@ key | action
## 2. Setting up

1. Install Karabiner. (I used [this Homebrew cask](https://formulae.brew.sh/cask/karabiner-elements) through `brew cask install karabiner-elements`.)
2. Import this complex modification straight into Karabiner through this link:

2. **Easy Installation** (recommended):
```bash
# Clone this repository
git clone https://git.sr.ht/~harmtemolder/karabiner-vim-mode-plus
cd karabiner-vim-mode-plus

# Install dependencies and deploy to Karabiner
pnpm install
pnpm run deploy
```

3. **Manual Installation**:
Import this complex modification straight into Karabiner through this link:

<a href="karabiner://karabiner/assets/complex_modifications/import?url=https://git.sr.ht/~harmtemolder/karabiner-vim-mode-plus/blob/master/vim_mode_plus.json" target="_blank">karabiner://karabiner/assets/complex_modifications/import?url=https://git.sr.ht/~harmtemolder/karabiner-vim-mode-plus/blob/master/vim_mode_plus.json</a>

(You might have to copy and paste it into your browser's address bar if your browser does not render it as a clickable link.)

4. **In Karabiner Elements**:
- Open Karabiner Elements Settings
- Go to "Complex modifications" tab
- Click "Add rule"
- Find "Vim Mode Plus" in the list
- **Add all rules IN ORDER** (this is critical!)

## 3. Making changes

I write my complex modifications in `YML` files, converting them into `JSON` using `yml-to-json.py`. You don't have to, but you can, if you want to. Either way, make sure to remove and re-add all parts of this mod in Karabiner's “Complex modifications” tab after making changes. The order they are in is important.
Expand Down
101 changes: 101 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env tsx

import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';

interface BuildOptions {
watch?: boolean;
}

function convertYmlToJson(ymlPath: string): void {
try {
// Read the YAML file
const ymlContent = fs.readFileSync(ymlPath, 'utf8');

// Parse YAML to JavaScript object
const yamlData = yaml.load(ymlContent);

// Generate JSON file path
const jsonPath = path.join(
path.dirname(ymlPath),
path.basename(ymlPath, '.yml') + '.json'
);

// Write JSON file with proper formatting
fs.writeFileSync(jsonPath, JSON.stringify(yamlData, null, 2), 'utf8');

const timestamp = new Date().toLocaleTimeString();
console.log(`[${timestamp}] ✓ Converted ${path.basename(ymlPath)} → ${path.basename(jsonPath)}`);
} catch (error) {
console.error(`✗ Error converting ${ymlPath}:`, error);
process.exit(1);
}
}

function findYamlFiles(directory: string): string[] {
const files = fs.readdirSync(directory);
return files
.filter(file => file.endsWith('.yml') || file.endsWith('.yaml'))
.map(file => path.join(directory, file));
}

function buildAll(directory: string = process.cwd()): void {
const yamlFiles = findYamlFiles(directory);

if (yamlFiles.length === 0) {
console.log('No YAML files found to convert.');
return;
}

console.log(`Found ${yamlFiles.length} YAML file(s) to convert:`);
yamlFiles.forEach(file => {
console.log(` - ${path.basename(file)}`);
});
console.log('');

yamlFiles.forEach(convertYmlToJson);

console.log('\\n🎉 Build complete!');
}

function watchFiles(directory: string = process.cwd()): void {
console.log('👀 Watching for changes...');

// Initial build
buildAll(directory);

// Watch for changes
fs.watch(directory, { recursive: false }, (eventType, filename) => {
if (filename && (filename.endsWith('.yml') || filename.endsWith('.yaml'))) {
if (eventType === 'change') {
console.log(`\\n📝 File changed: ${filename}`);
const filePath = path.join(directory, filename);
if (fs.existsSync(filePath)) {
convertYmlToJson(filePath);
}
}
}
});

console.log('\\nPress Ctrl+C to stop watching...\\n');
}

function main(): void {
const args = process.argv.slice(2);
const options: BuildOptions = {
watch: args.includes('--watch') || args.includes('-w')
};

const directory = process.cwd();

if (options.watch) {
watchFiles(directory);
} else {
buildAll(directory);
}
}

if (require.main === module) {
main();
}
87 changes: 87 additions & 0 deletions deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/bin/bash

# Karabiner Vim Mode Plus Installation Script
# This script installs the local vim_mode_plus.json to Karabiner Elements

set -e # Exit on any error

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}

print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}

print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}

print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}

# Check if we're in the right directory
if [[ ! -f "vim_mode_plus.json" ]]; then
print_error "vim_mode_plus.json not found in current directory"
print_error "Please run this script from the karabiner-vim-mode-plus project root"
exit 1
fi

# Check if Karabiner Elements is installed
KARABINER_DIR="$HOME/.config/karabiner"
ASSETS_DIR="$KARABINER_DIR/assets/complex_modifications"

if [[ ! -d "$KARABINER_DIR" ]]; then
print_error "Karabiner Elements configuration directory not found"
print_error "Please install Karabiner Elements first: https://karabiner-elements.pqrs.org/"
exit 1
fi

# Create assets directory if it doesn't exist
if [[ ! -d "$ASSETS_DIR" ]]; then
print_status "Creating Karabiner assets directory..."
mkdir -p "$ASSETS_DIR"
fi

# Build the latest JSON if needed
if [[ -f "build.ts" && -f "package.json" ]]; then
print_status "Building latest configuration..."
if command -v pnpm &> /dev/null; then
pnpm run build
elif command -v npm &> /dev/null; then
npm run build
else
print_warning "Neither pnpm nor npm found. Using existing JSON file."
fi
fi

# Copy the new configuration
print_status "Deploying Vim Mode Plus configuration..."
cp "vim_mode_plus.json" "$ASSETS_DIR/vim_mode_plus.json"

# Verify installation
if [[ -f "$ASSETS_DIR/vim_mode_plus.json" ]]; then
print_success "Installation completed successfully!"
echo
print_status "Next steps:"
echo "1. Open Karabiner Elements Settings"
echo "2. Go to 'Complex modifications' tab"
echo "3. Click 'Add rule'"
echo "4. Find 'Vim Mode Plus' in the list"
echo "5. Add all rules IN ORDER (this is important!)"
echo
print_warning "If you have old Vim Mode Plus rules, remove them first"
print_warning "The order of rules matters - add them in sequence"
else
print_error "Installation failed - file not copied correctly"
exit 1
fi
88 changes: 88 additions & 0 deletions get-bundle-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env tsx

import * as fs from 'fs';
import * as path from 'path';
import * as plist from 'plist';

/**
* Extract bundle identifier from a macOS .app bundle
* Usage: tsx get-bundle-id.ts /Applications/SomeApp.app
*/

function getBundleIdentifier(appPath: string): string | null {
try {
// Normalize the path and ensure it ends with .app
const normalizedPath = path.resolve(appPath);

if (!normalizedPath.endsWith('.app')) {
throw new Error('Path must point to a .app bundle');
}

if (!fs.existsSync(normalizedPath)) {
throw new Error(`App bundle not found: ${normalizedPath}`);
}

const infoPlistPath = path.join(normalizedPath, 'Contents', 'Info.plist');

if (!fs.existsSync(infoPlistPath)) {
throw new Error(`Info.plist not found in: ${infoPlistPath}`);
}

// Read and parse the plist file
const plistContent = fs.readFileSync(infoPlistPath, 'utf8');
const parsed = plist.parse(plistContent) as Record<string, any>;

const bundleId = parsed.CFBundleIdentifier;

if (!bundleId || typeof bundleId !== 'string') {
throw new Error('CFBundleIdentifier not found or invalid in Info.plist');
}

return bundleId;

} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
return null;
}
}

function printUsage() {
console.log('Usage:');
console.log(' tsx get-bundle-id.ts /Applications/SomeApp.app');
console.log(' tsx get-bundle-id.ts "/Applications/Visual Studio Code.app"');
console.log('');
console.log('Examples:');
console.log(' tsx get-bundle-id.ts /Applications/Safari.app');
console.log(' tsx get-bundle-id.ts /System/Applications/TextEdit.app');
}

function main() {
const args = process.argv.slice(2);

if (args.length === 0) {
console.error('Error: No app path provided\n');
printUsage();
process.exit(1);
}

if (args[0] === '--help' || args[0] === '-h') {
printUsage();
process.exit(0);
}

const appPath = args[0];
const bundleId = getBundleIdentifier(appPath);

if (bundleId) {
console.log(bundleId);
} else {
process.exit(1);
}
}

// If this script is run directly (not imported)
if (require.main === module) {
main();
}

export { getBundleIdentifier };
Loading