Skip to content

Instantly share code, notes, and snippets.

@lpolon
Last active April 16, 2026 17:25
Show Gist options
  • Select an option

  • Save lpolon/1bb5935bbacf73a85775644018682913 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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