Skip to content

Instantly share code, notes, and snippets.

@amrutadotorg
Last active January 21, 2026 21:49
Show Gist options
  • Select an option

  • Save amrutadotorg/ffb93d56dba158826659c99daed9e0e7 to your computer and use it in GitHub Desktop.

Select an option

Save amrutadotorg/ffb93d56dba158826659c99daed9e0e7 to your computer and use it in GitHub Desktop.
nvim + kickstarter custom install
#!/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