commit cbee7d87b84fd95c0cbd2c70baf579d536a21623 Author: retoor Date: Wed Sep 16 07:40:02 2026 +0000 WOrking perfect. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a56c792 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Claude Vim Filter Plugin + +Transform selected text in Vim/Neovim using Claude Code's `claude -p` command. + +## What It Does + +Select any text in Vim, press a keybinding, type an instruction (like "fix syntax" or "improve variable names"), and Claude transforms it instantly. Text is passed safely via stdin, so all special characters, quotes, newlines, and unicode work without escaping. + +## Install + +```bash +cd /path/to/vimai +python3 install.py +``` + +The installer auto-detects Vim or Neovim and copies the plugin to the right location. + +## Usage + +1. **Select text** - Visual mode: `V` + arrow keys +2. **Press `a`** - Default leader is `,` (comma) +3. **Type instruction** - Describe what you want +4. **Press Enter** - Done + +**Examples:** +```vim +fix syntax errors +add type hints +improve variable names +convert to uppercase +fix grammar +``` + +Or use the command directly: +```vim +:ClaudeFilter add docstring +``` + +That's it. + diff --git a/install.py b/install.py new file mode 100755 index 0000000..0fb43ca --- /dev/null +++ b/install.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +Claude Filter Vim Plugin - Automatic Installation Script + +Detects Vim/Neovim installation and installs the plugin with proper handling +for existing installations (updates/overrides). + +Features: +- Auto-detects Vim vs Neovim +- Auto-detects config directory +- Installs both Lua (Neovim) and VimScript (Vim) versions +- Handles existing installations (updates) +- Cross-platform (Linux, macOS, Windows) +""" + +import os +import sys +import shutil +import argparse +from pathlib import Path + + +class PluginInstaller: + def __init__(self, verbose=False, override=False): + self.verbose = verbose + self.override = override + self.installed = [] + self.errors = [] + + def log(self, message, level="INFO"): + """Log messages if verbose mode is enabled""" + if self.verbose or level in ["ERROR", "SUCCESS"]: + prefix = "✅" if level == "SUCCESS" else "❌" if level == "ERROR" else "ℹ️" + print(f"{prefix} [{level}] {message}") + + def check_editor_installed(self): + """Check which editors are installed""" + editors = {} + + # Check Neovim + if shutil.which("nvim"): + editors["neovim"] = True + self.log("Neovim detected") + else: + editors["neovim"] = False + self.log("Neovim not found", "WARN") + + # Check Vim + if shutil.which("vim"): + editors["vim"] = True + self.log("Vim detected") + else: + editors["vim"] = False + self.log("Vim not found", "WARN") + + return editors + + def get_neovim_config_dir(self): + """Get Neovim config directory (works on all platforms)""" + # Check XDG_CONFIG_HOME first (Linux/macOS) + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + nvim_dir = Path(xdg_config) / "nvim" + if nvim_dir.exists(): + return nvim_dir + + # Check standard locations + home = Path.home() + + # macOS + if sys.platform == "darwin": + nvim_dir = home / ".config" / "nvim" + if nvim_dir.exists(): + return nvim_dir + + # Linux + if sys.platform.startswith("linux"): + nvim_dir = home / ".config" / "nvim" + if nvim_dir.exists(): + return nvim_dir + + # Windows + if sys.platform == "win32": + # Try AppData first + appdata = os.environ.get("APPDATA") + if appdata: + nvim_dir = Path(appdata) / "nvim" + if nvim_dir.exists(): + return nvim_dir + + # Try local AppData + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + nvim_dir = Path(local_appdata) / "nvim" + if nvim_dir.exists(): + return nvim_dir + + # Fallback: Create in ~/.config/nvim + return home / ".config" / "nvim" + + def get_vim_config_dir(self): + """Get Vim config directory""" + home = Path.home() + + # Check for .vim directory + vim_dir = home / ".vim" + if vim_dir.exists(): + return vim_dir + + # Windows: check vimfiles + if sys.platform == "win32": + vimfiles = home / "vimfiles" + if vimfiles.exists(): + return vimfiles + + # Default to .vim + return vim_dir + + def install_neovim_plugin(self): + """Install Lua version for Neovim""" + self.log("Installing Neovim plugin...") + + nvim_dir = self.get_neovim_config_dir() + plugin_dir = nvim_dir / "plugin" + + # Create directory if it doesn't exist + plugin_dir.mkdir(parents=True, exist_ok=True) + + # Source and destination + source = Path(__file__).parent / "plugin" / "claude_filter.lua" + destination = plugin_dir / "claude_filter.lua" + + if not source.exists(): + self.log(f"Source file not found: {source}", "ERROR") + self.errors.append(f"Neovim: Source file not found") + return False + + # Check if already installed + if destination.exists(): + if not self.override: + self.log(f"Already installed at {destination}") + self.log("Use --override to update") + return True + else: + self.log(f"Removing existing: {destination}") + destination.unlink() + + # Copy file + try: + shutil.copy2(source, destination) + self.log(f"✅ Installed to {destination}", "SUCCESS") + self.installed.append(("Neovim", destination)) + return True + except Exception as e: + self.log(f"Failed to copy: {e}", "ERROR") + self.errors.append(f"Neovim: {str(e)}") + return False + + def install_vim_plugin(self): + """Install VimScript version for Vim""" + self.log("Installing Vim plugin...") + + vim_dir = self.get_vim_config_dir() + plugin_dir = vim_dir / "plugin" + + # Create directory if it doesn't exist + plugin_dir.mkdir(parents=True, exist_ok=True) + + # Source and destination + source = Path(__file__).parent / "plugin" / "claude_filter.vim" + destination = plugin_dir / "claude_filter.vim" + + if not source.exists(): + self.log(f"Source file not found: {source}", "ERROR") + self.errors.append(f"Vim: Source file not found") + return False + + # Check if already installed + if destination.exists(): + if not self.override: + self.log(f"Already installed at {destination}") + self.log("Use --override to update") + return True + else: + self.log(f"Removing existing: {destination}") + destination.unlink() + + # Copy file + try: + shutil.copy2(source, destination) + self.log(f"✅ Installed to {destination}", "SUCCESS") + self.installed.append(("Vim", destination)) + return True + except Exception as e: + self.log(f"Failed to copy: {e}", "ERROR") + self.errors.append(f"Vim: {str(e)}") + return False + + def install(self): + """Main installation logic""" + self.log("=" * 60) + self.log("Claude Filter Vim Plugin - Installer", "SUCCESS") + self.log("=" * 60) + + # Check what's installed + editors = self.check_editor_installed() + + if not any(editors.values()): + self.log("Neither Vim nor Neovim found on this system", "ERROR") + self.log("Install one of them first:") + self.log(" - Neovim: https://neovim.io") + self.log(" - Vim: https://www.vim.org") + return False + + success = True + + # Install for Neovim + if editors["neovim"]: + if not self.install_neovim_plugin(): + success = False + + # Install for Vim + if editors["vim"]: + if not self.install_vim_plugin(): + success = False + + # Summary + self.log("=" * 60) + if self.installed: + self.log("Installation Summary:", "SUCCESS") + for editor, path in self.installed: + self.log(f" {editor}: {path}") + + if self.errors: + self.log("Errors encountered:", "ERROR") + for error in self.errors: + self.log(f" - {error}") + + self.log("=" * 60) + + # Usage instructions + if self.installed: + self.log("\n✅ Installation successful!\n", "SUCCESS") + self.log("Usage:") + self.log(" 1. Select text in visual mode (V + arrows)") + self.log(" 2. Press a (usually comma+a)") + self.log(" 3. Enter your instruction") + self.log(" 4. Text is transformed instantly") + self.log("\nDocumentation:") + self.log(" - Quick start: QUICK_START.md") + self.log(" - Detailed setup: SETUP.md") + self.log(" - Test cases: TEST_CASES.md") + self.log(" - Technical details: TECHNICAL.md") + return True + else: + self.log("Installation failed", "ERROR") + return False + + def verify_installation(self): + """Verify that plugin files exist and are readable""" + self.log("\nVerifying installation...") + + all_good = True + for editor, path in self.installed: + if path.exists() and path.is_file(): + size = path.stat().st_size + self.log(f"✓ {editor} plugin verified ({size} bytes)") + else: + self.log(f"✗ {editor} plugin not found at {path}", "ERROR") + all_good = False + + return all_good + + +def main(): + parser = argparse.ArgumentParser( + description="Install Claude Filter Vim plugin", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python3 install.py # Install with auto-detection + python3 install.py --verbose # Show detailed output + python3 install.py --override # Update existing installation + python3 install.py -v -o # Both verbose and override + """ + ) + + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Show detailed installation messages" + ) + + parser.add_argument( + "-o", "--override", + action="store_true", + help="Override existing installation (update)" + ) + + parser.add_argument( + "--verify", + action="store_true", + help="Only verify existing installation" + ) + + args = parser.parse_args() + + installer = PluginInstaller(verbose=args.verbose, override=args.override) + + if args.verify: + installer.install() + installer.verify_installation() + else: + success = installer.install() + if success: + installer.verify_installation() + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugin/claude_filter.lua b/plugin/claude_filter.lua new file mode 100644 index 0000000..68303bd --- /dev/null +++ b/plugin/claude_filter.lua @@ -0,0 +1,211 @@ +-- Claude Filter Plugin for Neovim/Vim +-- Transforms selected text via claude -p with user-provided prompts +-- Usage: Select text in visual mode, press a, enter your instruction + +local M = {} + +-- Configuration +M.config = { + model = "haiku", -- Model to use + system_prompt = [[You are a helpful text transformation assistant. +When transforming text: +1. Only change what was requested +2. Preserve formatting intent +3. Return only the transformed text, no explanations]], +} + +--- Initialize with custom options +function M.setup(opts) + M.config = vim.tbl_extend("force", M.config, opts or {}) +end + +--- Get visual selection boundaries +--- Returns: text, start_line, end_line, start_col, end_col +local function get_visual_selection() + -- Mark positions: '< is start, '> is end + local s_start = vim.fn.getpos("'<") + local s_end = vim.fn.getpos("'>") + + if not s_start or not s_end then + return nil, nil, nil, nil, nil + end + + local start_line = s_start[2] + local start_col = s_start[3] + local end_line = s_end[2] + local end_col = s_end[3] + + -- Get all lines in selection + local lines = vim.fn.getline(start_line, end_line) + if type(lines) == "string" then + lines = { lines } + end + + -- Handle single-line selection + if #lines == 1 then + local text = string.sub(lines[1], start_col, end_col) + return text, start_line, end_line, start_col, end_col + end + + -- Handle multi-line selection + local text = string.sub(lines[1], start_col) + for i = 2, #lines - 1 do + text = text .. "\n" .. lines[i] + end + text = text .. "\n" .. string.sub(lines[#lines], 1, end_col) + + return text, start_line, end_line, start_col, end_col +end + +--- Replace visual selection with new text (preserves surrounding text on same line) +local function replace_selection(new_text, start_line, end_line, start_col, end_col) + local split_lines = vim.split(new_text, "\n") + + -- Single-line selection: preserve text before and after + if start_line == end_line then + local current_line = vim.fn.getline(start_line) + local before = string.sub(current_line, 1, start_col - 1) + local after = string.sub(current_line, end_col + 1) + split_lines[1] = before .. split_lines[1] .. after + end + + -- Replace lines (vim uses 0-based indexing for nvim_buf_set_lines) + vim.api.nvim_buf_set_lines(0, start_line - 1, end_line, false, split_lines) +end + +--- Execute claude -p with selected text via stdin +--- @param text string The selected text +--- @param prompt string The user's instruction +--- @return string|nil Output text or nil on error +--- @return string|nil Error message +local function execute_claude(text, prompt) + if not text or text == "" then + return nil, "No text selected" + end + + if not prompt or prompt == "" then + return nil, "No prompt provided" + end + + -- Build command with specified model + local cmd = { + "claude", + "-p", + prompt, + "--model", + M.config.model, + } + + -- If system prompt is set, add it + if M.config.system_prompt and M.config.system_prompt ~= "" then + table.insert(cmd, "--append-system-prompt") + table.insert(cmd, M.config.system_prompt) + end + + -- Execute with stdin approach (SAFE for all special chars, newlines, etc) + -- Text goes to stdin (2nd argument), not parsed by shell + local output = vim.fn.system(cmd, text) + + -- Check for errors + if vim.v.shell_error ~= 0 then + return nil, "Claude error: " .. output + end + + -- Remove trailing newline if present (claude may add one) + if output:sub(-1) == "\n" then + output = output:sub(1, -2) + end + + return output, nil +end + +--- Main filter function: prompt user and transform selection +function M.filter_interactive() + -- Get selection + local selected_text, start_line, end_line, start_col, end_col = get_visual_selection() + + if not selected_text or selected_text == "" then + vim.notify("No text selected", vim.log.levels.WARN) + return + end + + -- Get prompt from user + local prompt = vim.fn.input({ + prompt = "Enter instruction for Claude: ", + cancelreturn = "", + }) + + if prompt == "" then + return + end + + -- Execute (no notifications - cleaner flow) + local result, err = execute_claude(selected_text, prompt) + + if err then + vim.notify(err, vim.log.levels.ERROR) + return + end + + -- Replace selection (preserves surrounding text) + replace_selection(result, start_line, end_line, start_col, end_col) +end + +--- Apply filter with pre-defined prompt (non-interactive) +function M.filter(prompt) + if not prompt or prompt == "" then + vim.notify("Prompt cannot be empty", vim.log.levels.WARN) + return + end + + -- Get selection + local selected_text, start_line, end_line, start_col, end_col = get_visual_selection() + + if not selected_text or selected_text == "" then + vim.notify("No text selected", vim.log.levels.WARN) + return + end + + -- Execute (no notifications - cleaner flow, no extra Enter) + local result, err = execute_claude(selected_text, prompt) + + if err then + vim.notify(err, vim.log.levels.ERROR) + return + end + + -- Replace selection (preserves surrounding text) + replace_selection(result, start_line, end_line, start_col, end_col) +end + +--- Setup plugin: create commands and mappings +function M.setup_mappings() + -- Interactive command: prompts for instruction + vim.api.nvim_create_user_command( + "ClaudeFilter", + function(opts) + if opts.args and opts.args ~= "" then + -- Called with argument: use it as prompt + M.filter(opts.args) + else + -- Called without argument: prompt interactively + M.filter_interactive() + end + end, + { nargs = "*", range = true } + ) + + -- Visual mode mapping: press a to get interactive prompt + vim.keymap.set( + "v", + "a", + M.filter_interactive, + { noremap = true, silent = true, desc = "Claude filter (interactive)" } + ) +end + +-- Auto-setup if called +M.setup() +M.setup_mappings() + +return M diff --git a/plugin/claude_filter.vim b/plugin/claude_filter.vim new file mode 100644 index 0000000..22872fb --- /dev/null +++ b/plugin/claude_filter.vim @@ -0,0 +1,117 @@ +" Claude Filter Plugin - VimScript Version +" For Vim users or those who prefer VimScript +" Usage: Select text in visual mode, then :ClaudeFilter "your instruction" +" Or press a and enter instruction at prompt + +if exists('g:loaded_claude_filter_vim') + finish +endif +let g:loaded_claude_filter_vim = 1 + +" Configuration +let g:claude_filter_model = get(g:, 'claude_filter_model', 'haiku') +let g:claude_filter_system_prompt = get(g:, 'claude_filter_system_prompt', + \ "You are a helpful text transformation assistant.\n" . + \ "When transforming text:\n" . + \ "1. Only change what was requested\n" . + \ "2. Preserve formatting intent\n" . + \ "3. Return only the transformed text, no explanations") + +" Main function: Apply Claude filter to selection with given prompt +function! s:ApplyClaudeFilter(prompt) range + if empty(a:prompt) + echohl ErrorMsg + echo 'Error: Prompt cannot be empty' + echohl None + return + endif + + " Get selection boundaries (handles partial-line selections) + let start_pos = getpos("'<") + let end_pos = getpos("'>") + let start_line = start_pos[1] + let start_col = start_pos[2] + let end_line = end_pos[1] + let end_col = end_pos[2] + + " Get selected lines + let lines = getline(start_line, end_line) + + " Extract selected text (respecting column boundaries) + let selected_text = '' + if len(lines) == 1 + " Single line: extract substring between columns + let selected_text = strpart(lines[0], start_col - 1, end_col - start_col + 1) + else + " Multiple lines: first line from col, middle lines full, last line to col + let selected_text = strpart(lines[0], start_col - 1) + for i in range(1, len(lines) - 2) + let selected_text = selected_text . "\n" . lines[i] + endfor + let selected_text = selected_text . "\n" . strpart(lines[-1], 0, end_col) + endif + + if empty(selected_text) + echohl ErrorMsg + echo 'Error: No text selected' + echohl None + return + endif + + " Build command with model + let cmd = 'claude -p ' . shellescape(a:prompt) . ' --model ' . shellescape(g:claude_filter_model) + + " Add system prompt if set + if !empty(g:claude_filter_system_prompt) + let cmd = cmd . ' --append-system-prompt ' . shellescape(g:claude_filter_system_prompt) + endif + + " Execute via stdin (safe approach) + " system() with two args: first is command, second is stdin input + " Text goes to stdin, NOT parsed by shell - safe for all special chars + let output = system(cmd, selected_text) + + " Check for errors + if v:shell_error != 0 + echohl ErrorMsg + echo 'Claude error: ' . output + echohl None + return + endif + + " Remove trailing newline if present + let output = substitute(output, '\n$', '', '') + + " Split output into lines + let output_lines = split(output, '\n') + + " Replace selection, preserving surrounding text on same line + if start_line == end_line + " Single-line selection: preserve text before and after + let current_line = getline(start_line) + let before = strpart(current_line, 0, start_col - 1) + let after = strpart(current_line, end_col) + let output_lines[0] = before . output_lines[0] . after + endif + + " Delete old lines and insert new ones + silent execute start_line . ',' . end_line . 'delete' + call append(start_line - 1, output_lines) +endfunction + +" Command: :ClaudeFilter "your instruction" +command! -range -nargs=+ ClaudeFilter ,call s:ApplyClaudeFilter() + +" Function for interactive mode (prompts user) +function! s:InteractiveClaudeFilter() range + let prompt = input('Enter instruction for Claude: ') + if !empty(prompt) + execute a:firstline . ',' . a:lastline . 'call s:ApplyClaudeFilter(prompt)' + endif +endfunction + +" Command for interactive mode +command! -range ClaudeFilterPrompt ,call s:InteractiveClaudeFilter() + +" Visual mode mapping: a +vnoremap a :ClaudeFilterPrompt