1212from fastmcp .utilities .types import Image , ContentBlock
1313from 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-
2015OMARCHY_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
2325mcp = 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
2946def _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+
4967async 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
8193def 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+
89156async 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+
107175def 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+
118195def text_result (text : str ) -> TextContent :
119196 """Helper to create a TextContent result."""
120197 return TextContent (type = "text" , text = text )
121198
199+
122200@mcp .tool ()
123201async 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 ()
185266async 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 ()
216295async 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
286356def main ():
0 commit comments