Last active
April 16, 2026 17:25
-
-
Save lpolon/1bb5935bbacf73a85775644018682913 to your computer and use it in GitHub Desktop.
Generate iTerm2 Dynamic Profile from VS Code theme settings (font, colors, light/dark)
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 | |
| """ | |
| vscode-to-iterm2.py — Generate an iTerm2 Dynamic Profile from your VS Code themes. | |
| Reads VS Code settings.json to discover: | |
| - Font family & size | |
| - Preferred dark and light color themes | |
| Then locates the theme extension files, extracts terminal colors, | |
| writes an iTerm2 Dynamic Profile that follows macOS appearance switching, | |
| and sets it as the default iTerm2 profile. | |
| Run it directly from the gist: | |
| curl -sL https://gist.githubusercontent.com/lpolon/1bb5935bbacf73a85775644018682913/raw/vscode-to-iterm2.py | python3 | |
| Local usage: | |
| python3 vscode-to-iterm2.py [--dry-run] | |
| To change the profile name, edit the PROFILE_NAME constant below. | |
| """ | |
| # Edit this to rename the generated profile. Keep it stable across runs — | |
| # the script preserves the GUID by matching on this name. | |
| PROFILE_NAME = "job" | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import uuid | |
| # --- Paths ------------------------------------------------------------------- | |
| VSCODE_SETTINGS = os.path.expanduser( | |
| "~/Library/Application Support/Code/User/settings.json" | |
| ) | |
| VSCODE_EXTENSIONS = os.path.expanduser("~/.vscode/extensions") | |
| ITERM_DYNAMIC_PROFILES = os.path.expanduser( | |
| "~/Library/Application Support/iTerm2/DynamicProfiles" | |
| ) | |
| # --- Helpers ------------------------------------------------------------------ | |
| def load_jsonc(path): | |
| """Load a JSONC file (JSON with comments and trailing commas).""" | |
| with open(path) as f: | |
| text = f.read() | |
| # Strip comments while preserving strings (which may contain // or /*) | |
| result = [] | |
| i = 0 | |
| while i < len(text): | |
| # String literal — copy verbatim | |
| if text[i] == '"': | |
| j = i + 1 | |
| while j < len(text): | |
| if text[j] == '\\': | |
| j += 2 | |
| elif text[j] == '"': | |
| j += 1 | |
| break | |
| else: | |
| j += 1 | |
| result.append(text[i:j]) | |
| i = j | |
| # Single-line comment | |
| elif text[i:i+2] == '//': | |
| while i < len(text) and text[i] != '\n': | |
| i += 1 | |
| # Block comment | |
| elif text[i:i+2] == '/*': | |
| i = text.index('*/', i + 2) + 2 | |
| else: | |
| result.append(text[i]) | |
| i += 1 | |
| text = ''.join(result) | |
| text = re.sub(r",\s*([}\]])", r"\1", text) | |
| return json.loads(text) | |
| def hex_to_rgb(hex_color): | |
| """Return (r, g, b) floats in 0-1 from a hex string.""" | |
| h = hex_color.lstrip("#") | |
| return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255) | |
| def blend_alpha(fg_hex, bg_hex): | |
| """Blend an #RRGGBBAA color over an opaque #RRGGBB background.""" | |
| h = fg_hex.lstrip("#") | |
| if len(h) <= 6: | |
| return fg_hex | |
| alpha = int(h[6:8], 16) / 255 | |
| fr, fg, fb = int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255 | |
| br, bg_, bb = hex_to_rgb(bg_hex) | |
| r = fr * alpha + br * (1 - alpha) | |
| g = fg * alpha + bg_ * (1 - alpha) | |
| b = fb * alpha + bb * (1 - alpha) | |
| return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255)) | |
| def color_dict(hex_color): | |
| """Convert #RRGGBB to an iTerm2 color component dict.""" | |
| if not hex_color: | |
| return None | |
| r, g, b = hex_to_rgb(hex_color.lstrip("#")[:6]) | |
| return { | |
| "Red Component": round(r, 6), | |
| "Green Component": round(g, 6), | |
| "Blue Component": round(b, 6), | |
| "Color Space": "sRGB", | |
| } | |
| # --- VS Code theme discovery ------------------------------------------------- | |
| def find_theme_file(theme_name): | |
| """Walk VS Code extensions to find the JSON file for a theme label/id.""" | |
| if not os.path.isdir(VSCODE_EXTENSIONS): | |
| return None | |
| for entry in os.listdir(VSCODE_EXTENSIONS): | |
| ext_dir = os.path.join(VSCODE_EXTENSIONS, entry) | |
| pkg_path = os.path.join(ext_dir, "package.json") | |
| if not os.path.isfile(pkg_path): | |
| continue | |
| try: | |
| pkg = load_jsonc(pkg_path) | |
| except Exception: | |
| continue | |
| for theme in pkg.get("contributes", {}).get("themes", []): | |
| if theme.get("label") == theme_name or theme.get("id") == theme_name: | |
| rel = theme.get("path", "") | |
| full = os.path.normpath(os.path.join(ext_dir, rel)) | |
| if os.path.isfile(full): | |
| return full | |
| return None | |
| def resolve_theme(theme_path): | |
| """Load a theme file, following 'include' chains.""" | |
| data = load_jsonc(theme_path) | |
| if "include" in data: | |
| parent_path = os.path.normpath( | |
| os.path.join(os.path.dirname(theme_path), data["include"]) | |
| ) | |
| if os.path.isfile(parent_path): | |
| parent = resolve_theme(parent_path) | |
| merged_colors = {**parent.get("colors", {}), **data.get("colors", {})} | |
| data["colors"] = merged_colors | |
| return data | |
| def extract_colors(theme_data): | |
| """Extract terminal-relevant colors from a resolved VS Code theme.""" | |
| c = theme_data.get("colors", {}) | |
| bg = c.get("terminal.background") or c.get("editor.background") or "#1e1e1e" | |
| fg = c.get("terminal.foreground") or c.get("editor.foreground") or "#cccccc" | |
| selection_raw = ( | |
| c.get("terminal.selectionBackground") | |
| or c.get("editor.selectionBackground") | |
| or "#264f78" | |
| ) | |
| selection = blend_alpha(selection_raw, bg) | |
| return { | |
| "bg": bg, | |
| "fg": fg, | |
| "bold": c.get("terminal.foreground") or fg, | |
| "cursor": c.get("terminalCursor.foreground") | |
| or c.get("editorCursor.foreground") | |
| or fg, | |
| "cursor_text": c.get("terminalCursor.background") or bg, | |
| "selection": selection, | |
| "selected_text": c.get("terminal.selectionForeground") or fg, | |
| "link": c.get("textLink.foreground") or "#3794ff", | |
| "badge": c.get("badge.background") or "#007acc", | |
| "ansi": [ | |
| c.get("terminal.ansiBlack", "#000000"), | |
| c.get("terminal.ansiRed", "#cd3131"), | |
| c.get("terminal.ansiGreen", "#0dbc79"), | |
| c.get("terminal.ansiYellow", "#e5e510"), | |
| c.get("terminal.ansiBlue", "#2472c8"), | |
| c.get("terminal.ansiMagenta", "#bc3fbc"), | |
| c.get("terminal.ansiCyan", "#11a8cd"), | |
| c.get("terminal.ansiWhite", "#e5e5e5"), | |
| c.get("terminal.ansiBrightBlack", "#666666"), | |
| c.get("terminal.ansiBrightRed", "#f14c4c"), | |
| c.get("terminal.ansiBrightGreen", "#23d18b"), | |
| c.get("terminal.ansiBrightYellow", "#f5f543"), | |
| c.get("terminal.ansiBrightBlue", "#3b8eea"), | |
| c.get("terminal.ansiBrightMagenta", "#d670d6"), | |
| c.get("terminal.ansiBrightCyan", "#29b8db"), | |
| c.get("terminal.ansiBrightWhite", "#e5e5e5"), | |
| ], | |
| } | |
| # --- Font discovery ----------------------------------------------------------- | |
| def parse_font_family(css_value): | |
| """Return the first non-generic font name from a CSS font-family string.""" | |
| for part in css_value.split(","): | |
| name = part.strip().strip("'\"") | |
| if name.lower() not in ("monospace", "sans-serif", "serif"): | |
| return name | |
| return None | |
| def find_postscript_name(family_name): | |
| """Use fc-list to find the PostScript name for a font family.""" | |
| try: | |
| result = subprocess.run( | |
| ["fc-list", "--format", "%{postscriptname}\\n", f":family={family_name}"], | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| ) | |
| names = [n.strip() for n in result.stdout.strip().split("\n") if n.strip()] | |
| for n in names: | |
| if "Regular" in n: | |
| return n | |
| return names[0] if names else None | |
| except Exception: | |
| return None | |
| # --- Profile generation ------------------------------------------------------- | |
| PROFILE_COLOR_KEYS = [ | |
| ("Background Color", "bg"), | |
| ("Foreground Color", "fg"), | |
| ("Bold Color", "bold"), | |
| ("Cursor Color", "cursor"), | |
| ("Cursor Text Color", "cursor_text"), | |
| ("Selection Color", "selection"), | |
| ("Selected Text Color", "selected_text"), | |
| ("Link Color", "link"), | |
| ("Badge Color", "badge"), | |
| ] | |
| def build_profile(profile_name, font_str, dark_colors, light_colors, profile_guid): | |
| """Assemble the iTerm2 profile dict.""" | |
| profile = { | |
| "Name": profile_name, | |
| "Guid": profile_guid, | |
| "Normal Font": font_str, | |
| "Non Ascii Font": font_str, | |
| "Use Ligatures": True, | |
| "Use Bold Font": True, | |
| "Use Italic Font": True, | |
| "Horizontal Spacing": 1.0, | |
| "Vertical Spacing": 1.0, | |
| "Cursor Type": 2, | |
| "Use Separate Colors for Light and Dark Mode": True, | |
| "Transparency": 0.0, | |
| "Blur": False, | |
| "Scrollback Lines": 100000, | |
| } | |
| for key, field in PROFILE_COLOR_KEYS: | |
| dc = color_dict(dark_colors[field]) | |
| lc = color_dict(light_colors[field]) | |
| if dc: | |
| profile[key] = dc | |
| profile[f"{key} (Dark)"] = dc | |
| if lc: | |
| profile[f"{key} (Light)"] = lc | |
| for i in range(16): | |
| key = f"Ansi {i} Color" | |
| dc = color_dict(dark_colors["ansi"][i]) | |
| lc = color_dict(light_colors["ansi"][i]) | |
| if dc: | |
| profile[key] = dc | |
| profile[f"{key} (Dark)"] = dc | |
| if lc: | |
| profile[f"{key} (Light)"] = lc | |
| profile["Smart Cursor Color (Dark)"] = False | |
| profile["Smart Cursor Color (Light)"] = False | |
| profile["Use Selected Text Color (Dark)"] = True | |
| profile["Use Selected Text Color (Light)"] = True | |
| return profile | |
| # --- Main --------------------------------------------------------------------- | |
| def set_default_profile(guid): | |
| """Set the iTerm2 default profile GUID via `defaults write`.""" | |
| try: | |
| current = subprocess.run( | |
| ["defaults", "read", "com.googlecode.iterm2", "Default Bookmark Guid"], | |
| capture_output=True, text=True, | |
| ).stdout.strip() | |
| except Exception: | |
| current = "" | |
| if current == guid: | |
| print(f" Default profile already set (GUID matches).") | |
| return True | |
| iterm_running = subprocess.run( | |
| ["pgrep", "-x", "iTerm2"], capture_output=True | |
| ).returncode == 0 | |
| subprocess.run( | |
| ["defaults", "write", "com.googlecode.iterm2", | |
| "Default Bookmark Guid", "-string", guid], | |
| check=True, | |
| ) | |
| print(f" Set '{PROFILE_NAME}' as iTerm2 default profile.") | |
| if iterm_running: | |
| print(" NOTE: iTerm2 is running — quit and relaunch it to avoid having") | |
| print(" this setting overwritten when iTerm2 flushes its prefs.") | |
| return True | |
| def main(): | |
| args = sys.argv[1:] | |
| profile_name = PROFILE_NAME | |
| output_path = os.path.join(ITERM_DYNAMIC_PROFILES, f"{profile_name}.json") | |
| dry_run = False | |
| i = 0 | |
| while i < len(args): | |
| if args[i] == "--dry-run": | |
| dry_run = True | |
| i += 1 | |
| elif args[i] in ("--help", "-h"): | |
| print(__doc__) | |
| sys.exit(0) | |
| else: | |
| print(f"Unknown option: {args[i]}", file=sys.stderr) | |
| sys.exit(1) | |
| # 1. Read VS Code settings | |
| if not os.path.isfile(VSCODE_SETTINGS): | |
| print(f"Error: VS Code settings not found at {VSCODE_SETTINGS}", file=sys.stderr) | |
| sys.exit(1) | |
| settings = load_jsonc(VSCODE_SETTINGS) | |
| print(f" Read VS Code settings") | |
| # 2. Resolve themes | |
| dark_theme_name = settings.get( | |
| "workbench.preferredDarkColorTheme", | |
| settings.get("workbench.colorTheme", "Default Dark+"), | |
| ) | |
| light_theme_name = settings.get( | |
| "workbench.preferredLightColorTheme", | |
| settings.get("workbench.colorTheme", "Default Light+"), | |
| ) | |
| print(f" Dark theme: {dark_theme_name}") | |
| print(f" Light theme: {light_theme_name}") | |
| dark_file = find_theme_file(dark_theme_name) | |
| light_file = find_theme_file(light_theme_name) | |
| if not dark_file: | |
| print(f"Error: Could not find theme file for '{dark_theme_name}'", file=sys.stderr) | |
| sys.exit(1) | |
| if not light_file: | |
| print(f"Error: Could not find theme file for '{light_theme_name}'", file=sys.stderr) | |
| sys.exit(1) | |
| print(f" Dark file: {dark_file}") | |
| print(f" Light file: {light_file}") | |
| dark_data = resolve_theme(dark_file) | |
| light_data = resolve_theme(light_file) | |
| dark_colors = extract_colors(dark_data) | |
| light_colors = extract_colors(light_data) | |
| # 3. Resolve font | |
| font_family_css = settings.get("editor.fontFamily", "Menlo") | |
| font_size = settings.get("editor.fontSize", 14) | |
| family_name = parse_font_family(font_family_css) | |
| print(f" Font family: {family_name} ({font_size}pt)") | |
| ps_name = find_postscript_name(family_name) if family_name else None | |
| if ps_name: | |
| font_str = f"{ps_name} {font_size}" | |
| print(f" PostScript: {ps_name}") | |
| else: | |
| font_str = f"MenloRegular {font_size}" | |
| print(f" Warning: Could not resolve '{family_name}', falling back to Menlo") | |
| # 4. Preserve GUID across runs to avoid duplicating profiles | |
| existing_guid = None | |
| if os.path.isfile(output_path): | |
| try: | |
| existing = load_jsonc(output_path) | |
| for p in existing.get("Profiles", []): | |
| if p.get("Name") == profile_name: | |
| existing_guid = p.get("Guid") | |
| break | |
| except Exception: | |
| pass | |
| profile_guid = existing_guid or str(uuid.uuid4()).upper() | |
| # 5. Build and write | |
| profile = build_profile(profile_name, font_str, dark_colors, light_colors, profile_guid) | |
| output = json.dumps({"Profiles": [profile]}, indent=2) + "\n" | |
| if dry_run: | |
| print() | |
| print(output) | |
| return | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| with open(output_path, "w") as f: | |
| f.write(output) | |
| print(f" Wrote profile to {output_path}") | |
| set_default_profile(profile_guid) | |
| print(f" Done. New iTerm2 windows will use '{profile_name}'.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment