@@ -95,25 +95,40 @@ def get_allowed_mcp_tools(
9595 ]
9696
9797
98- # Source skills directory - check multiple locations
99- # 1. Sibling to this app (local development): ../../databricks-skills
100- # 2. Deployed location (Databricks Apps): ./skills at app root
101- _DEV_SKILLS_DIR = Path (__file__ ).parent .parent .parent .parent / 'databricks-skills'
102- _DEPLOYED_SKILLS_DIR = Path (__file__ ).parent .parent .parent / 'skills'
98+ # Skills source directories. install_skills.sh aggregates skills from
99+ # multiple repos (this repo's databricks-skills/, mlflow/skills, apx) into
100+ # the app's .claude/skills/ directory. We check several locations so that
101+ # the server works both in local development and when deployed.
102+ #
103+ # Candidate source directories (checked in priority order):
104+ # 1. .claude/skills/ inside the app — populated by install_skills.sh with
105+ # the *full* union of Databricks + MLflow + APX skills.
106+ # 2. Sibling ../../databricks-skills — the repo-local Databricks-only skills.
107+ # 3. ./skills at app root — the deployed bundle location.
108+ _APP_ROOT = Path (__file__ ).parent .parent .parent
109+ _INSTALLED_SKILLS_DIR = _APP_ROOT / '.claude' / 'skills'
110+ _DEV_SKILLS_DIR = _APP_ROOT .parent / 'databricks-skills'
111+ _DEPLOYED_SKILLS_DIR = _APP_ROOT / 'skills'
103112
104113# Local cache of skills within this app (copied on startup)
105- APP_SKILLS_DIR = Path ( __file__ ). parent . parent . parent / 'skills'
114+ APP_SKILLS_DIR = _APP_ROOT / 'skills'
106115
107- # Prefer dev location (sibling repo) when available to avoid self-deletion:
108- # APP_SKILLS_DIR == _DEPLOYED_SKILLS_DIR, so using deployed as source would
109- # delete the source during copy_skills_to_app(). Only fall back to deployed
110- # location when the dev source repo isn't available (actual deployment).
111- if _DEV_SKILLS_DIR .exists () and any (_DEV_SKILLS_DIR .iterdir ()):
112- SKILLS_SOURCE_DIR = _DEV_SKILLS_DIR
113- elif _DEPLOYED_SKILLS_DIR .exists () and any (_DEPLOYED_SKILLS_DIR .iterdir ()):
114- SKILLS_SOURCE_DIR = _DEPLOYED_SKILLS_DIR
115- else :
116- SKILLS_SOURCE_DIR = _DEV_SKILLS_DIR
116+ def _non_empty_dir (p : Path ) -> bool :
117+ return p .exists () and p .is_dir () and any (p .iterdir ())
118+
119+ # Build an ordered list of source directories. The first directory that
120+ # contains a given skill wins, so put the most-complete source first.
121+ _SKILLS_SOURCE_DIRS : list [Path ] = []
122+ if _non_empty_dir (_INSTALLED_SKILLS_DIR ):
123+ _SKILLS_SOURCE_DIRS .append (_INSTALLED_SKILLS_DIR )
124+ if _non_empty_dir (_DEV_SKILLS_DIR ):
125+ _SKILLS_SOURCE_DIRS .append (_DEV_SKILLS_DIR )
126+ if _non_empty_dir (_DEPLOYED_SKILLS_DIR ) and _DEPLOYED_SKILLS_DIR .resolve () != APP_SKILLS_DIR .resolve ():
127+ _SKILLS_SOURCE_DIRS .append (_DEPLOYED_SKILLS_DIR )
128+
129+ # Legacy single-directory reference used by callers that haven't been
130+ # updated yet. Points to the first available source.
131+ SKILLS_SOURCE_DIR = _SKILLS_SOURCE_DIRS [0 ] if _SKILLS_SOURCE_DIRS else _DEV_SKILLS_DIR
117132
118133
119134def _get_enabled_skills () -> list [str ] | None :
@@ -190,8 +205,24 @@ class SkillNotFoundError(Exception):
190205 pass
191206
192207
208+ def _find_skill_source (skill_name : str ) -> Path | None :
209+ """Find a skill directory across all source directories.
210+
211+ Returns the first directory that contains ``skill_name/SKILL.md``.
212+ """
213+ for src_dir in _SKILLS_SOURCE_DIRS :
214+ candidate = src_dir / skill_name
215+ if candidate .is_dir () and (candidate / 'SKILL.md' ).exists ():
216+ return candidate
217+ return None
218+
219+
193220def copy_skills_to_app () -> bool :
194- """Copy skills from source repo to app's skills directory.
221+ """Copy skills from source directories to app's skills directory.
222+
223+ Skills may originate from multiple locations (the repo's databricks-skills/,
224+ the .claude/skills/ directory populated by install_skills.sh, or the
225+ deployed skills bundle). This function merges them into APP_SKILLS_DIR.
195226
196227 Called on server startup to ensure we have the latest skills.
197228 Only copies skills listed in ENABLED_SKILLS env var (if set).
@@ -200,68 +231,71 @@ def copy_skills_to_app() -> bool:
200231 True if successful, False otherwise
201232
202233 Raises:
203- SkillNotFoundError: If an enabled skill folder doesn't exist or lacks SKILL.md
234+ SkillNotFoundError: If an enabled skill folder doesn't exist in any
235+ source directory or lacks SKILL.md.
204236 """
205- if not SKILLS_SOURCE_DIR .exists ():
206- logger .warning (f'Skills source directory not found: { SKILLS_SOURCE_DIR } ' )
237+ if not _SKILLS_SOURCE_DIRS :
238+ # No external source directories found. In a deployed app the skills
239+ # are bundled directly into APP_SKILLS_DIR (== _DEPLOYED_SKILLS_DIR),
240+ # so there is nothing to copy — skills are already in place.
241+ if _non_empty_dir (APP_SKILLS_DIR ):
242+ logger .info (f'No external source dirs; skills already in place at { APP_SKILLS_DIR } ' )
243+ return True
244+ logger .warning ('No skills source directories found' )
207245 return False
208246
209- # Guard against self-deletion: in deployed apps, SKILLS_SOURCE_DIR and
210- # APP_SKILLS_DIR resolve to the same directory. Deleting APP_SKILLS_DIR
211- # would destroy the source. Skills are already in place, so skip the copy.
212- if SKILLS_SOURCE_DIR .resolve () == APP_SKILLS_DIR .resolve ():
213- logger .info (f'Skills source and app directory are the same ({ APP_SKILLS_DIR } ), skipping copy' )
247+ # Guard against self-deletion: when every source *is* APP_SKILLS_DIR we
248+ # would wipe the only copy. Skills are already in place, so skip.
249+ all_same = all (
250+ src .resolve () == APP_SKILLS_DIR .resolve () for src in _SKILLS_SOURCE_DIRS
251+ )
252+ if all_same :
253+ logger .info (f'All skills sources resolve to { APP_SKILLS_DIR } , skipping copy' )
214254 return True
215255
216256 enabled_skills = _get_enabled_skills ()
217257 if enabled_skills :
218258 logger .info (f'Filtering skills to: { enabled_skills } ' )
219259
220- # Validate that all enabled skills exist before copying
221260 for skill_name in enabled_skills :
222- skill_path = SKILLS_SOURCE_DIR / skill_name
223- skill_md_path = skill_path / 'SKILL.md'
224-
225- if not skill_path .exists ():
261+ found = _find_skill_source (skill_name )
262+ if found is None :
263+ searched = ', ' .join (str (d ) for d in _SKILLS_SOURCE_DIRS )
226264 raise SkillNotFoundError (
227- f"Skill '{ skill_name } ' not found. "
228- f"Directory does not exist : { skill_path } . "
265+ f"Skill '{ skill_name } ' not found in any source directory . "
266+ f"Searched : { searched } . "
229267 f"Check ENABLED_SKILLS in your .env file."
230268 )
231269
232- if not skill_md_path .exists ():
233- raise SkillNotFoundError (
234- f"Skill '{ skill_name } ' is invalid. "
235- f"Missing SKILL.md file in: { skill_path } . "
236- f"Each skill must have a SKILL.md file."
237- )
238-
239270 try :
240- # Remove existing skills directory if it exists
241271 if APP_SKILLS_DIR .exists ():
242272 shutil .rmtree (APP_SKILLS_DIR )
243273
244- # Copy skill directories (filtered by ENABLED_SKILLS if set)
245274 APP_SKILLS_DIR .mkdir (parents = True , exist_ok = True )
246275
247- copied_count = 0
248- for item in SKILLS_SOURCE_DIR .iterdir ():
249- if item .is_dir () and (item / 'SKILL.md' ).exists ():
250- # Skip if not in enabled list (when list is specified)
276+ copied : set [str ] = set ()
277+ for src_dir in _SKILLS_SOURCE_DIRS :
278+ if src_dir .resolve () == APP_SKILLS_DIR .resolve ():
279+ continue
280+ for item in src_dir .iterdir ():
281+ if not item .is_dir () or not (item / 'SKILL.md' ).exists ():
282+ continue
283+ if item .name in copied :
284+ continue
251285 if enabled_skills and item .name not in enabled_skills :
252286 logger .debug (f'Skipping skill (not enabled): { item .name } ' )
253287 continue
254288
255289 dest = APP_SKILLS_DIR / item .name
256290 shutil .copytree (item , dest )
257- copied_count += 1
291+ copied . add ( item . name )
258292 logger .debug (f'Copied skill: { item .name } ' )
259293
260- logger .info (f'Copied { copied_count } skills to { APP_SKILLS_DIR } ' )
294+ logger .info (f'Copied { len ( copied ) } skills to { APP_SKILLS_DIR } ' )
261295 return True
262296
263297 except SkillNotFoundError :
264- raise # Re-raise validation errors
298+ raise
265299 except Exception as e :
266300 logger .error (f'Failed to copy skills: { e } ' )
267301 return False
0 commit comments