Skip to content

Commit ae7fc4c

Browse files
committed
tighten theme resolution
1 parent eab30c3 commit ae7fc4c

1 file changed

Lines changed: 131 additions & 61 deletions

File tree

src/omarchy_mcp/server.py

Lines changed: 131 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,36 @@
1212
from fastmcp.utilities.types import Image, ContentBlock
1313
from mcp.types import ImageContent, TextContent
1414

15-
# Load omarchy themes from JSON file on startup
16-
_themes_file = Path(__file__).parent / "omarchy_themes.json"
17-
with open(_themes_file, "r") as f:
18-
OMARCHY_THEMES = json.load(f)
19-
2015
OMARCHY_PATH = os.environ.get("OMARCHY_PATH", os.path.expanduser("~/.local/share/omarchy"))
2116

17+
ThemeFilter = StrEnum(
18+
"ThemeFilter",
19+
["ALL", "CURRENT", "INSTALLED", "BUILT_IN", "CAN_REMOVE", "CAN_INSTALL"],
20+
)
21+
22+
ColorScheme = StrEnum("ColorScheme", ["ANY", "LIGHT", "DARK"])
23+
2224
# Initialize FastMCP server
2325
mcp = FastMCP("omarchy-mcp")
2426

25-
ThemeFilter = StrEnum("ThemeFilter", ["ALL", "CURRENT", "INSTALLED", "BUILT_IN", "CAN_REMOVE", "CAN_INSTALL"])
2627

27-
ColorScheme = StrEnum("ColorScheme", ["ANY", "LIGHT", "DARK"])
28+
def sanitize(name: str) -> str:
29+
return name.replace(" ", "").replace("_", "").replace("-", "").lower()
30+
31+
32+
# Load Omarchy themes from JSON file on startup
33+
_themes_file = Path(__file__).parent / "omarchy_themes.json"
34+
with open(_themes_file, "r") as f:
35+
OMARCHY_THEMES = json.load(f)
36+
for theme in OMARCHY_THEMES:
37+
name = theme.get("name")
38+
github_url = theme.get("github_url")
39+
theme_name = theme.get("theme_name") # computed
40+
if github_url and not theme_name:
41+
repo_name = github_url.split("/")[-1]
42+
theme_name = sanitize(repo_name.replace("omarchy-", "").replace("-theme", ""))
43+
theme["theme_name"] = theme_name
44+
2845

2946
def _get_env() -> dict[str, str]:
3047
"""Get environment with OMARCHY_PATH, HOME, and Wayland session vars set."""
@@ -46,6 +63,7 @@ def _get_env() -> dict[str, str]:
4663

4764
return env
4865

66+
4967
async def run_command(*args: str) -> tuple[str, str]:
5068
"""Run a command and return (stdout, stderr) as UTF-8 strings.
5169
@@ -57,7 +75,7 @@ async def run_command(*args: str) -> tuple[str, str]:
5775
stdout=asyncio.subprocess.PIPE,
5876
stderr=asyncio.subprocess.PIPE,
5977
env=env,
60-
cwd=os.path.expanduser("~")
78+
cwd=os.path.expanduser("~"),
6179
)
6280
stdout, stderr = await process.communicate()
6381
stdout_str = stdout.decode("utf-8") if stdout else ""
@@ -71,21 +89,70 @@ async def run_command(*args: str) -> tuple[str, str]:
7189
raise RuntimeError(f"Command {args[0]} failed: {error_msg}")
7290
return stdout_str, stderr_str
7391

74-
def sanitize(name: str) -> str:
75-
return name.replace(" ", "").replace("_", "").replace("-", "").lower()
76-
77-
def matches_theme(name:str, theme_name:str) -> bool:
78-
"""Check if name matches theme_name (case-insensitive, partial match)."""
79-
return sanitize(name) in sanitize(theme_name)
8092

8193
def get_theme_by_name(name: str) -> Optional[dict]:
8294
"""Get theme dict from OMARCHY_THEMES by name (case-insensitive, partial match)."""
8395
sanitized_name = sanitize(name)
8496
ret = next((t for t in OMARCHY_THEMES if sanitized_name in sanitize(t["name"])), None)
85-
if not ret:
86-
raise ValueError(f"Theme '{name}' not found in available themes.")
97+
if ret:
98+
return ret
99+
ret = next(
100+
(t for t in OMARCHY_THEMES if "theme_name" in t and sanitized_name in sanitize(t["theme_name"])),
101+
None,
102+
)
87103
return ret
88104

105+
106+
def assert_theme_by_name(name: str) -> Optional[dict]:
107+
"""Assert theme exists"""
108+
ret = get_theme_by_name(name)
109+
if ret:
110+
return ret
111+
raise ValueError(f"Theme '{name}' not found in available themes.")
112+
113+
114+
def matches_theme(name: str, theme_name: str) -> bool:
115+
"""Check if name matches theme_name (case-insensitive, partial match)."""
116+
return sanitize(name) in sanitize(theme_name)
117+
118+
119+
def theme_matches(theme: str, *args) -> bool:
120+
"""Check if theme matches any of the provided names (case-insensitive, partial match)."""
121+
sanitized_theme = sanitize(theme)
122+
for name in args:
123+
if name and sanitize(name) in sanitized_theme:
124+
return True
125+
return False
126+
127+
128+
def themes_contains(themes: list[str], *args) -> bool:
129+
"""Check if themes contains any of the provided names (case-insensitive, partial match)."""
130+
sanitized_themes = {sanitize(t) for t in themes}
131+
for name in args:
132+
if not name:
133+
continue
134+
sanitized_name = sanitize(name)
135+
for sanitized_theme in sanitized_themes:
136+
if sanitized_name in sanitized_theme:
137+
return True
138+
return False
139+
140+
141+
def find_matching_theme(existing_themes, name: str) -> Optional[str]:
142+
"""Find a matching theme from existing_themes by name (case-insensitive, partial match)."""
143+
if name in existing_themes:
144+
return name
145+
# Try to match sanitized names if exact name not found
146+
omarchy_theme = get_theme_by_name(name)
147+
for existing_theme in existing_themes:
148+
if omarchy_theme:
149+
if theme_matches(existing_theme, name, omarchy_theme.get("name"), omarchy_theme.get("theme_name")):
150+
return existing_theme
151+
else:
152+
if matches_theme(name, existing_theme):
153+
return existing_theme
154+
155+
89156
async def get_theme_preview_image(theme_info) -> Optional[ImageContent]:
90157
if theme_info and "preview_url" in theme_info:
91158
preview_url = theme_info["preview_url"]
@@ -104,21 +171,32 @@ async def get_installed_themes() -> list[str]:
104171
stdout, _ = await run_command(f"{OMARCHY_PATH}/bin/omarchy-theme-list")
105172
return stdout.strip().splitlines()
106173

174+
107175
def get_installed_extra_themes() -> list[str]:
108176
"""Get a list of installed Omarchy extra themes (not symlinks)."""
109177
omarchy_themes = Path.home() / ".config/omarchy/themes"
110178
if not omarchy_themes.exists():
111179
return []
112-
installed_theme_names = [
113-
d.name for d in omarchy_themes.iterdir()
114-
if d.is_dir() and not d.is_symlink()
115-
]
180+
installed_theme_names = [d.name for d in omarchy_themes.iterdir() if d.is_dir() and not d.is_symlink()]
116181
return installed_theme_names
117182

183+
184+
def get_current_background():
185+
current_background_link = Path.home() / ".config/omarchy/current/background"
186+
if current_background_link.is_symlink():
187+
current_bg = current_background_link.resolve()
188+
if current_bg.is_file():
189+
img_data = current_bg.read_bytes()
190+
fmt = current_bg.suffix.lower().lstrip(".")
191+
return Image(data=img_data, format=fmt or "png").to_image_content()
192+
return None
193+
194+
118195
def text_result(text: str) -> TextContent:
119196
"""Helper to create a TextContent result."""
120197
return TextContent(type="text", text=text)
121198

199+
122200
@mcp.tool()
123201
async def omarchy_theme_list(filter: ThemeFilter = ThemeFilter.INSTALLED, scheme: ColorScheme = ColorScheme.ANY) -> str:
124202
"""Get a list of Omarchy themes."""
@@ -142,6 +220,8 @@ def display_themes(themes: list[str]) -> list[str]:
142220
for theme in themes:
143221
if scheme != ColorScheme.ANY:
144222
omarchy_theme = get_theme_by_name(theme)
223+
if not omarchy_theme:
224+
continue
145225
theme_scheme = omarchy_theme.get("scheme", "Any").lower()
146226
if theme_scheme != scheme.value.lower():
147227
continue
@@ -176,32 +256,27 @@ def display_themes(themes: list[str]) -> list[str]:
176256
if sanitize(theme["name"]) not in sanitized_installed:
177257
available_themes.append(theme["name"])
178258
return display_themes(available_themes)
179-
259+
180260
# ThemeFilter.ALL - All themes
181261
all_themes = [theme["name"] for theme in OMARCHY_THEMES]
182262
return display_themes(all_themes)
183263

264+
184265
@mcp.tool()
185266
async def omarchy_theme_set(theme: str) -> ContentBlock:
186267
"""Set the current Omarchy theme."""
187268
set_theme = None
188-
installed_themes = await get_installed_themes()
189269

190-
for t in installed_themes:
191-
if matches_theme(theme, t):
192-
set_theme = t
193-
break
270+
installed_themes = await get_installed_themes()
271+
set_theme = find_matching_theme(installed_themes, theme)
194272

195273
if not set_theme:
196-
return text_result(f"You need to install theme '{theme}' first before setting it.")
197-
198-
omarchy_theme = get_theme_by_name(set_theme)
199-
name = omarchy_theme["name"]
274+
return text_result(f"The theme '{theme}' hasn't been installed yet.")
200275

201276
current_theme, _ = await run_command("omarchy-theme-current")
202277

203-
if matches_theme(theme, current_theme) or matches_theme(name, current_theme):
204-
return text_result(f"Theme '{name}' is already the current theme.")
278+
if theme_matches(current_theme, theme):
279+
return text_result(f"Theme '{current_theme}' is already the current theme.")
205280

206281
stdout, stderr = await run_command("omarchy-theme-set", set_theme)
207282

@@ -210,40 +285,43 @@ async def omarchy_theme_set(theme: str) -> ContentBlock:
210285
if current_theme == new_theme and stderr:
211286
return text_result(f"Failed to set theme '{name}': {stderr}")
212287

288+
omarchy_theme = get_theme_by_name(theme)
289+
if not omarchy_theme:
290+
return get_current_background()
213291
return await get_theme_preview_image(omarchy_theme)
214292

293+
215294
@mcp.tool()
216295
async def omarchy_theme_bg_next() -> ImageContent | None:
217296
"""Cycle to the next Omarchy theme background."""
218297
stdout, _ = await run_command("omarchy-theme-bg-next")
219-
current_background_link = Path.home() / ".config/omarchy/current/background"
220-
if current_background_link.is_symlink():
221-
current_bg = current_background_link.resolve()
222-
if current_bg.is_file():
223-
img_data = current_bg.read_bytes()
224-
fmt = current_bg.suffix.lower().lstrip(".")
225-
return Image(data=img_data, format=fmt or "png").to_image_content()
226-
return None
298+
return get_current_background()
299+
227300

228301
@mcp.tool()
229-
async def omarchy_preview_theme(name:str) -> Optional[ImageContent]:
302+
async def omarchy_preview_theme(name: str) -> Optional[ImageContent]:
230303
"""Get a preview image for an Omarchy theme by name."""
231304
omarchy_theme = get_theme_by_name(name)
305+
if not omarchy_theme:
306+
return text_result(f"Theme '{name}' not found in available themes.")
232307
return await get_theme_preview_image(omarchy_theme)
233308

309+
234310
@mcp.tool()
235-
async def omarchy_install_theme(name:str) -> Optional[ContentBlock]:
311+
async def omarchy_install_theme(name: str) -> Optional[ContentBlock]:
236312
"""Install an Omarchy extra theme by name. Installing a theme automatically sets it as the current theme."""
237313
omarchy_theme = get_theme_by_name(name)
314+
if not omarchy_theme:
315+
return text_result(f"Theme '{name}' not found in available themes.")
238316

239317
installed_themes = await get_installed_themes()
240-
if omarchy_theme["name"] in installed_themes:
241-
raise RuntimeError(f"Theme '{name}' is already installed.")
318+
if find_matching_theme(installed_themes, name):
319+
return text_result(f"Theme '{name}' is already installed.")
242320

243321
github_url = omarchy_theme.get("github_url")
244322
if not github_url:
245323
return text_result(f"Theme '{name}' does not have a GitHub URL for installation.")
246-
324+
247325
current_theme, _ = await run_command("omarchy-theme-current")
248326

249327
stdout, stderr = await run_command("omarchy-theme-install", github_url)
@@ -256,31 +334,23 @@ async def omarchy_install_theme(name:str) -> Optional[ContentBlock]:
256334
# After installation, get the theme preview image
257335
return await get_theme_preview_image(omarchy_theme)
258336

337+
259338
@mcp.tool()
260-
async def omarchy_remove_theme(name:str) -> ContentBlock:
339+
async def omarchy_remove_theme(name: str) -> ContentBlock:
261340
"""Uninstall an Omarchy extra theme by name. Built-in themes cannot be removed."""
262341

263-
installed_theme_names = get_installed_extra_themes()
264-
265-
uninstall_theme = name if name in installed_theme_names else None
266-
267-
# Try to match sanitized names if exact name not found
268-
if name not in installed_theme_names:
269-
omarchy_theme = get_theme_by_name(name)
270-
271-
for installed_name in installed_theme_names:
272-
if sanitize(omarchy_theme["name"]) == sanitize(installed_name) or sanitize(name) in sanitize(installed_name):
273-
uninstall_theme = installed_name
274-
break
342+
installed_themes = get_installed_extra_themes()
343+
uninstall_theme = find_matching_theme(installed_themes, name)
275344

276345
if not uninstall_theme:
277-
return text_result(f"Theme '{name}' is not an installed extra theme:\n" + f" {'\n '.join(installed_theme_names)}")
346+
return text_result(f"Theme '{name}' is not an installed extra theme:\n" + f" {'\n '.join(installed_themes)}")
278347

279348
stdout, stderr = await run_command("omarchy-theme-remove", uninstall_theme)
280349

281350
if stderr:
282-
return stderr
283-
return stdout.strip() or "OK"
351+
return text_result(f"Failed to remove theme '{uninstall_theme}': {stderr}")
352+
353+
return text_result(stdout.strip() or "OK")
284354

285355

286356
def main():

0 commit comments

Comments
 (0)