Last active
January 21, 2026 21:49
-
-
Save amrutadotorg/ffb93d56dba158826659c99daed9e0e7 to your computer and use it in GitHub Desktop.
nvim + kickstarter custom install
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Neovim Custom Config Installer | |
| Replaces setup.sh with reliable cross-platform Python implementation. | |
| Targeting Neovim v0.11+ compatibility with Kickstart. | |
| """ | |
| import os | |
| import re | |
| import shutil | |
| import stat | |
| import subprocess | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import List | |
| # ANSI color codes for terminal output | |
| class Colors: | |
| RED = "\033[0;31m" | |
| GREEN = "\033[0;32m" | |
| YELLOW = "\033[1;33m" | |
| BLUE = "\033[0;34m" | |
| NC = "\033[0m" # No Color | |
| def print_colored(text: str, color: str = Colors.NC): | |
| """Print colored text""" | |
| print(f"{color}{text}{Colors.NC}") | |
| def print_header(text: str): | |
| """Print section header""" | |
| print() | |
| print_colored(f"{'='*50}", Colors.BLUE) | |
| print_colored(text, Colors.BLUE) | |
| print_colored(f"{'='*50}", Colors.BLUE) | |
| print() | |
| def ask_yes_no(prompt: str, default: bool = False) -> bool: | |
| """Ask user for yes/no confirmation""" | |
| suffix = " (Y/n): " if default else " (y/N): " | |
| while True: | |
| try: | |
| response = input(prompt + suffix).strip().lower() | |
| except EOFError: | |
| return default | |
| if not response: | |
| return default | |
| if response in ["y", "yes"]: | |
| return True | |
| if response in ["n", "no"]: | |
| return False | |
| print("Please answer 'y' or 'n'") | |
| def remove_readonly(func, path, exc_info): | |
| """ | |
| Error handler for shutil.rmtree. | |
| If the error is due to an access error (read only file), | |
| it attempts to add write permission and then retries. | |
| """ | |
| # Clear the readonly bit and reattempt the removal | |
| try: | |
| os.chmod(path, stat.S_IWRITE) | |
| func(path) | |
| except Exception: | |
| pass | |
| class LuaPatcher: | |
| """Handles safe patching of init.lua with validation""" | |
| def __init__(self, file_path: Path): | |
| self.file_path = file_path | |
| if not self.file_path.exists(): | |
| raise FileNotFoundError(f"File not found: {file_path}") | |
| self.content = self.file_path.read_text(encoding="utf-8") | |
| self.original_content = self.content | |
| self.changes = [] | |
| def inject_before_section(self, section_marker: str, code_to_inject: str) -> bool: | |
| """Inject code before a section marker (e.g., 'Basic Keymaps')""" | |
| pattern = rf"(--\s*\[+\s*{re.escape(section_marker)}\s*\]+)" | |
| if code_to_inject.strip() in self.content: | |
| self.changes.append( | |
| f"⚠️ Already exists: {code_to_inject.splitlines()[0]}..." | |
| ) | |
| return False | |
| match = re.search(pattern, self.content) | |
| if not match: | |
| self.changes.append(f"❌ Section not found: {section_marker}") | |
| return False | |
| pos = match.start() | |
| self.content = self.content[:pos] + code_to_inject + "\n" + self.content[pos:] | |
| self.changes.append(f"✓ Injected before: {section_marker}") | |
| return True | |
| def uncomment_line(self, pattern: str) -> bool: | |
| """Uncomment a line matching the pattern""" | |
| # Matches: -- { import = 'custom.plugins' } | |
| regex = rf"^(\s*)--\s*({re.escape(pattern)})" | |
| # Check if already active | |
| if re.search(rf"^(\s*){re.escape(pattern)}", self.content, re.MULTILINE): | |
| self.changes.append(f"⚠️ Already active: {pattern}") | |
| return False | |
| match = re.search(regex, self.content, re.MULTILINE) | |
| # If commented line exists, uncomment it | |
| if match: | |
| self.content = re.sub( | |
| regex, r"\1\2", self.content, count=1, flags=re.MULTILINE | |
| ) | |
| self.changes.append(f"✓ Uncommented: {pattern}") | |
| return True | |
| # Fallback: if line doesn't exist, try to inject into lazy setup | |
| if "import" in pattern: | |
| lazy_setup = r"(require\('lazy'\)\.setup\({)" | |
| if re.search(lazy_setup, self.content): | |
| self.content = re.sub( | |
| lazy_setup, rf"\1\n {pattern},", self.content, count=1 | |
| ) | |
| self.changes.append(f"✓ Injected missing: {pattern}") | |
| return True | |
| self.changes.append(f"❌ Pattern not found: {pattern}") | |
| return False | |
| def comment_line(self, pattern: str) -> bool: | |
| """Comment out a line matching the pattern""" | |
| regex = rf"^(\s*)({re.escape(pattern)}.*?)$" | |
| if re.search(rf"^(\s*)--.*{re.escape(pattern)}", self.content, re.MULTILINE): | |
| self.changes.append(f"⚠️ Already commented: {pattern}") | |
| return False | |
| match = re.search(regex, self.content, re.MULTILINE) | |
| if not match: | |
| self.changes.append(f"❌ Pattern not found: {pattern}") | |
| return False | |
| self.content = re.sub( | |
| regex, r"\1-- \2", self.content, count=1, flags=re.MULTILINE | |
| ) | |
| self.changes.append(f"✓ Commented out: {pattern}") | |
| return True | |
| def add_to_mason_tools(self, tools: List[str]) -> bool: | |
| """Add tools to Mason's ensure_installed list in init.lua""" | |
| # Filter out tools that are already explicitly listed | |
| tools_to_add = [t for t in tools if f"'{t}'" not in self.content] | |
| if not tools_to_add: | |
| self.changes.append("⚠️ All tools already present in Mason") | |
| return False | |
| # Matches "'stylua'," followed by anything (comment or newline) | |
| pattern = r"(\s*)'stylua',.*" | |
| match = re.search(pattern, self.content, re.MULTILINE) | |
| if not match: | |
| self.changes.append("❌ Could not find 'stylua' in Mason config") | |
| return False | |
| indent = match.group(1) | |
| tool_lines = "\n".join(f"{indent}'{tool}'," for tool in tools_to_add) | |
| pos = match.end() | |
| self.content = self.content[:pos] + "\n" + tool_lines + self.content[pos:] | |
| self.changes.append(f"✓ Added Mason tools: {', '.join(tools_to_add)}") | |
| return True | |
| def save(self, backup: bool = True) -> None: | |
| """Save changes to file with optional backup""" | |
| if self.content == self.original_content: | |
| print_colored("No changes needed for init.lua", Colors.YELLOW) | |
| return | |
| if backup: | |
| backup_path = self.file_path.with_suffix(".lua.bak") | |
| backup_path.write_text(self.original_content, encoding="utf-8") | |
| print_colored(f"📦 Backup created: {backup_path.name}", Colors.GREEN) | |
| self.file_path.write_text(self.content, encoding="utf-8") | |
| print_colored(f"💾 Saved patches to: {self.file_path.name}", Colors.GREEN) | |
| def print_summary(self) -> None: | |
| """Print summary of all changes""" | |
| for change in self.changes: | |
| if change.startswith("✓"): | |
| print_colored(change, Colors.GREEN) | |
| elif change.startswith("⚠️"): | |
| print_colored(change, Colors.YELLOW) | |
| elif change.startswith("❌"): | |
| print_colored(change, Colors.RED) | |
| else: | |
| print(change) | |
| class NeovimInstaller: | |
| """Main installer orchestrator""" | |
| def __init__(self): | |
| # Determine config directories using standard XDG paths | |
| home = Path.home() | |
| xdg_config = os.getenv("XDG_CONFIG_HOME", home / ".config") | |
| xdg_data = os.getenv("XDG_DATA_HOME", home / ".local" / "share") | |
| xdg_state = os.getenv("XDG_STATE_HOME", home / ".local" / "state") | |
| xdg_cache = os.getenv("XDG_CACHE_HOME", home / ".cache") | |
| self.nvim_config = Path(xdg_config) / "nvim" | |
| self.nvim_data = Path(xdg_data) / "nvim" | |
| self.nvim_state = Path(xdg_state) / "nvim" | |
| self.nvim_cache = Path(xdg_cache) / "nvim" | |
| self.custom_dir = self.nvim_config / "lua" / "custom" | |
| self.plugins_dir = self.custom_dir / "plugins" | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| self.backup_dir = Path(xdg_config) / f"nvim.backup.{timestamp}" | |
| def backup_existing_config(self) -> bool: | |
| """Backup existing config if it exists""" | |
| if not self.nvim_config.exists(): | |
| return True | |
| print_colored(f"Found existing config at: {self.nvim_config}", Colors.YELLOW) | |
| if not ask_yes_no("Do you want to back it up and continue?", default=True): | |
| print_colored("Installation cancelled.", Colors.RED) | |
| return False | |
| print_colored(f"Backing up to: {self.backup_dir}", Colors.GREEN) | |
| shutil.move(str(self.nvim_config), str(self.backup_dir)) | |
| return True | |
| def deep_clean(self) -> None: | |
| """Clean existing plugin data/cache (Recommended for v0.11 upgrade)""" | |
| if ( | |
| not self.nvim_data.exists() | |
| and not self.nvim_state.exists() | |
| and not self.nvim_cache.exists() | |
| ): | |
| return | |
| print() | |
| print_colored("Found existing plugin data/cache.", Colors.YELLOW) | |
| print_colored( | |
| "Recommended to clean this to prevent Treesitter version conflicts.", | |
| Colors.YELLOW, | |
| ) | |
| if not ask_yes_no("Delete old plugin data?", default=True): | |
| return | |
| dirs_to_clean = [self.nvim_data, self.nvim_state, self.nvim_cache] | |
| for dir_path in dirs_to_clean: | |
| if dir_path.exists(): | |
| try: | |
| # onerror=remove_readonly handles locked files (like git .idx or .lock) | |
| shutil.rmtree(dir_path, onerror=remove_readonly) | |
| print_colored(f"✓ Cleaned: {dir_path}", Colors.GREEN) | |
| except PermissionError: | |
| print_colored(f"✗ Permission denied cleaning: {dir_path}", Colors.RED) | |
| print_colored( | |
| " This usually means the files were created with 'sudo'.", | |
| Colors.YELLOW, | |
| ) | |
| print_colored( | |
| f" Please run manually: sudo rm -rf {dir_path}", Colors.YELLOW | |
| ) | |
| except Exception as e: | |
| print_colored(f"✗ Failed to clean {dir_path}: {e}", Colors.RED) | |
| def check_dependencies(self) -> None: | |
| """Check for external dependencies (Node.js)""" | |
| print_header("Checking dependencies") | |
| # Check for Node.js | |
| node_path = shutil.which("node") | |
| if node_path: | |
| # Try to get version | |
| try: | |
| result = subprocess.run( | |
| ["node", "--version"], capture_output=True, text=True | |
| ) | |
| version = result.stdout.strip() | |
| print_colored(f"✓ Node.js is installed ({version})", Colors.GREEN) | |
| except Exception: | |
| print_colored("✓ Node.js is installed", Colors.GREEN) | |
| else: | |
| print_colored("! Node.js not found.", Colors.YELLOW) | |
| print_colored( | |
| " Mason requires Node.js to install Pyright (Python LSP).", | |
| Colors.YELLOW, | |
| ) | |
| # Check for Homebrew | |
| if shutil.which("brew"): | |
| if ask_yes_no("Install Node.js via Homebrew?", default=True): | |
| try: | |
| print_colored("Installing Node.js...", Colors.BLUE) | |
| subprocess.run(["brew", "install", "node"], check=True) | |
| print_colored("✓ Node.js installed successfully", Colors.GREEN) | |
| except subprocess.CalledProcessError: | |
| print_colored( | |
| "✗ Failed to install Node.js via brew", Colors.RED | |
| ) | |
| else: | |
| print_colored( | |
| "⚠ Skipping Node.js installation. Pyright may fail to install.", | |
| Colors.YELLOW, | |
| ) | |
| else: | |
| print_colored( | |
| "⚠ Homebrew not found. Please install Node.js manually.", | |
| Colors.YELLOW, | |
| ) | |
| def clone_kickstart(self) -> bool: | |
| """Clone kickstart-modular.nvim repo""" | |
| print_header("Cloning kickstart-modular.nvim") | |
| try: | |
| subprocess.run( | |
| [ | |
| "git", | |
| "clone", | |
| "https://github.com/oriori1703/kickstart-modular.nvim", | |
| str(self.nvim_config), | |
| ], | |
| check=True, | |
| capture_output=True, | |
| ) | |
| print_colored("✓ Cloned successfully", Colors.GREEN) | |
| return True | |
| except subprocess.CalledProcessError as e: | |
| print_colored(f"✗ Failed to clone: {e}", Colors.RED) | |
| return False | |
| except FileNotFoundError: | |
| print_colored("✗ Git command not found. Please install git.", Colors.RED) | |
| return False | |
| def patch_init_lua(self) -> bool: | |
| """Patch init.lua with all modifications""" | |
| print_header("Patching init.lua") | |
| init_lua = self.nvim_config / "init.lua" | |
| try: | |
| patcher = LuaPatcher(init_lua) | |
| # 1. Inject Custom Options (Must be before Keymaps) | |
| patcher.inject_before_section( | |
| "Basic Keymaps", "-- Load custom options\nrequire('custom.options')" | |
| ) | |
| # 2. Inject Custom Keymaps | |
| patcher.inject_before_section( | |
| "Basic Autocommands", | |
| "-- Load custom keymaps\nrequire('custom.keymaps')", | |
| ) | |
| # 3. Enable Custom Plugins | |
| patcher.uncomment_line("{ import = 'custom.plugins' }") | |
| # 4. Disable default colorscheme | |
| patcher.comment_line("vim.cmd.colorscheme") | |
| # 5. Add Python tools to Mason | |
| patcher.add_to_mason_tools( | |
| ["ruff", "isort", "black", "pyright", "jsonls", "prettier", "taplo"] | |
| ) | |
| # Print results and save | |
| patcher.print_summary() | |
| patcher.save(backup=True) | |
| return True | |
| except Exception as e: | |
| print_colored(f"✗ Patching failed: {e}", Colors.RED) | |
| return True | |
| def create_custom_files(self) -> bool: | |
| """Create custom configuration files""" | |
| print_header("Creating custom files") | |
| try: | |
| # Create directories | |
| self.plugins_dir.mkdir(parents=True, exist_ok=True) | |
| # --- options.lua --- | |
| (self.custom_dir / "options.lua").write_text( | |
| """vim.g.have_nerd_font = true | |
| vim.schedule(function() | |
| local has_osc52, osc52 = pcall(require, 'vim.ui.clipboard.osc52') | |
| if has_osc52 then | |
| vim.g.clipboard = { | |
| name = 'OSC 52', | |
| copy = { ['+'] = osc52.copy '+', ['*'] = osc52.copy '*' }, | |
| paste = { ['+'] = osc52.paste '+', ['*'] = osc52.paste '*' }, | |
| } | |
| vim.o.clipboard = 'unnamedplus' | |
| end | |
| end) | |
| """, | |
| encoding="utf-8", | |
| ) | |
| print_colored("✓ Created options.lua", Colors.GREEN) | |
| # --- keymaps.lua --- | |
| (self.custom_dir / "keymaps.lua").write_text( | |
| """-- Select all | |
| vim.keymap.set('n', '<C-a>', 'ggVG', { noremap = true, silent = true, desc = 'Select all' }) | |
| -- Move lines | |
| vim.keymap.set('n', '<S-Up>', ':m .-2<CR>==', { desc = 'Move line up' }) | |
| vim.keymap.set('n', '<S-Down>', ':m .+1<CR>==', { desc = 'Move line down' }) | |
| vim.keymap.set('i', '<S-Up>', '<Esc>:m .-2<CR>==gi', { desc = 'Move line up' }) | |
| vim.keymap.set('i', '<S-Down>', '<Esc>:m .+1<CR>==gi', { desc = 'Move line down' }) | |
| vim.keymap.set('v', '<S-Up>', ":m '<-2<CR>gv=gv", { desc = 'Move selection up' }) | |
| vim.keymap.set('v', '<S-Down>', ":m '>+1<CR>gv=gv", { desc = 'Move selection down' }) | |
| -- Comment toggling | |
| vim.keymap.set('v', '//', "<ESC><cmd>lua require('Comment.api').toggle.linewise(vim.fn.visualmode())<CR>", { desc = 'Toggle comment' }) | |
| vim.keymap.set('n', '//', "<cmd>lua require('Comment.api').toggle.linewise.current()<CR>", { desc = 'Toggle comment' }) | |
| vim.keymap.set('i', '<C-n>', '<C-n>', { noremap = true }) | |
| """, | |
| encoding="utf-8", | |
| ) | |
| print_colored("✓ Created keymaps.lua", Colors.GREEN) | |
| # --- monokai.lua --- | |
| (self.plugins_dir / "monokai.lua").write_text( | |
| """return { | |
| 'tanvirtin/monokai.nvim', | |
| priority = 1000, | |
| config = function() | |
| require('monokai').setup() | |
| vim.cmd.colorscheme('monokai') | |
| end, | |
| } | |
| """, | |
| encoding="utf-8", | |
| ) | |
| print_colored("✓ Created monokai.lua", Colors.GREEN) | |
| # --- overrides.lua --- | |
| (self.plugins_dir / "overrides.lua").write_text( | |
| """return { | |
| { | |
| 'nvim-treesitter/nvim-treesitter', | |
| branch = 'master', -- FORCE STABLE BRANCH | |
| opts = function(_, opts) | |
| vim.list_extend(opts.ensure_installed, { 'python', 'json', 'jsonc', 'toml' }) | |
| opts.textobjects = { select = { enable = true, lookahead = true, keymaps = { ['af'] = '@function.outer', ['if'] = '@function.inner', ['ac'] = '@class.outer', ['ic'] = '@class.inner' } } } | |
| return opts | |
| end, | |
| }, | |
| { | |
| 'stevearc/conform.nvim', | |
| opts = function(_, opts) | |
| opts.formatters_by_ft = opts.formatters_by_ft or {} | |
| -- Use ruff_fix to lint/fix errors, isort to sort imports, black to format | |
| opts.formatters_by_ft.python = { 'ruff_fix', 'isort', 'black' } | |
| opts.formatters_by_ft.json = { 'prettier' } | |
| opts.formatters_by_ft.jsonc = { 'prettier' } | |
| opts.formatters_by_ft.toml = { 'taplo' } | |
| opts.format_on_save = { timeout_ms = 3000, lsp_fallback = true } | |
| return opts | |
| end, | |
| }, | |
| { | |
| 'folke/which-key.nvim', | |
| opts = { | |
| delay = 500, | |
| spec = { | |
| { '/', hidden = true }, | |
| }, -- Fix instant popup annoyance | |
| }, | |
| }, | |
| } | |
| """, | |
| encoding="utf-8", | |
| ) | |
| print_colored("✓ Created overrides.lua", Colors.GREEN) | |
| # --- Standard Plugins --- | |
| plugins = { | |
| "comment.lua": "return { 'numToStr/Comment.nvim', event = { 'BufReadPre', 'BufNewFile' }, config = function() require('Comment').setup() end }", | |
| "tmux.lua": "return { 'christoomey/vim-tmux-navigator', cmd = { 'TmuxNavigateLeft', 'TmuxNavigateDown', 'TmuxNavigateUp', 'TmuxNavigateRight' }, keys = { { '<C-h>', '<cmd>TmuxNavigateLeft<CR>', desc = 'Window left' }, { '<C-j>', '<cmd>TmuxNavigateDown<CR>', desc = 'Window down' }, { '<C-k>', '<cmd>TmuxNavigateUp<CR>', desc = 'Window up' }, { '<C-l>', '<cmd>TmuxNavigateRight<CR>', desc = 'Window right' } } }", | |
| "textobjects.lua": "return { 'nvim-treesitter/nvim-treesitter-textobjects', dependencies = { 'nvim-treesitter/nvim-treesitter' }, event = { 'BufReadPre', 'BufNewFile' } }", | |
| } | |
| for filename, content in plugins.items(): | |
| (self.plugins_dir / filename).write_text(content, encoding="utf-8") | |
| print_colored(f"✓ Created plugins/{filename}", Colors.GREEN) | |
| return True | |
| except Exception as e: | |
| print_colored(f"✗ Failed to create files: {e}", Colors.RED) | |
| return False | |
| def run(self) -> int: | |
| """Run the complete installation""" | |
| print_colored("================================", Colors.BLUE) | |
| print_colored("Neovim Custom Config Installer", Colors.BLUE) | |
| print_colored("================================", Colors.BLUE) | |
| print() | |
| # 1. Backup | |
| if not self.backup_existing_config(): | |
| return 1 | |
| # 2. Deep clean | |
| self.deep_clean() | |
| # 3. Check Dependencies (Node.js) | |
| self.check_dependencies() | |
| # 4. Clone | |
| if not self.clone_kickstart(): | |
| return 1 | |
| # 5. Patch init.lua | |
| if not self.patch_init_lua(): | |
| return 1 | |
| # 6. Create custom files | |
| if not self.create_custom_files(): | |
| return 1 | |
| print() | |
| print_colored("=" * 50, Colors.GREEN) | |
| print_colored("Installation Complete!", Colors.GREEN) | |
| print_colored("=" * 50, Colors.GREEN) | |
| print() | |
| print_colored("Next steps:", Colors.BLUE) | |
| print(" 1. Run: nvim") | |
| print(" 2. Wait for Mason to install Python tools (ruff, isort, black)") | |
| print(" 3. Restart Neovim once everything is quiet.") | |
| print() | |
| return 0 | |
| def main(): | |
| try: | |
| installer = NeovimInstaller() | |
| return installer.run() | |
| except KeyboardInterrupt: | |
| print() | |
| print_colored("\nInstallation cancelled by user.", Colors.YELLOW) | |
| return 130 | |
| except Exception as e: | |
| print_colored(f"\n✗ Unexpected error: {e}", Colors.RED) | |
| import traceback | |
| traceback.print_exc() | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment