3838
3939@dataclass (frozen = True )
4040class SkillRef :
41- """A downloadable skill's two names, which are not interchangeable.
42-
43- ``securable_name`` is the UC leaf of ``skills/<cat>.<sch>.<leaf>`` and is the
44- only name the Files API resolves, so it addresses the bytes and identifies the
45- skill. ``bundle_name`` is the ``name:`` an agent reads from the bundle's
46- SKILL.md frontmatter, so it names the on-disk directory. Finalize does not
47- require the two to match, so a skill created under a securable that differs
48- from its frontmatter carries both.
41+ """A downloadable skill's UC location plus its two non-interchangeable names.
42+
43+ ``catalog``/``schema``/``securable_name`` are the parts of ``skills/<cat>.<sch>.<leaf>``:
44+ ``securable_name`` is the leaf, the only name the Files API resolves, and the
45+ three together fully qualify the skill (``fqn``). ``bundle_name`` is the
46+ ``name:`` an agent reads from the bundle's SKILL.md frontmatter, so it names
47+ the on-disk directory. Finalize does not require the securable and bundle name
48+ to match, so a skill created under a securable that differs from its
49+ frontmatter carries both.
4950 """
5051
52+ catalog : str
53+ schema : str
5154 securable_name : str
5255 bundle_name : str
5356
57+ @property
58+ def fqn (self ) -> str :
59+ return f"{ self .catalog } .{ self .schema } .{ self .securable_name } "
60+
5461
5562def _non_empty_str (value : object ) -> str | None :
5663 """``value`` when it is a non-empty string, else None."""
5764 return value if isinstance (value , str ) and value else None
5865
5966
67+ def _is_safe_bundle_name (bundle_name : str ) -> bool :
68+ path = Path (bundle_name )
69+ return len (path .parts ) == 1 and path .parts [0 ] != ".." and not path .is_absolute ()
70+
71+
6072def _skill_ref (skill : dict ) -> SkillRef | None :
6173 """A finalized skill's ``SkillRef``, or None if it cannot be downloaded.
6274
@@ -86,7 +98,18 @@ def _skill_ref(skill: dict) -> SkillRef | None:
8698 )
8799 return None
88100
89- return SkillRef (securable_name = name .rsplit ("." , 1 )[- 1 ], bundle_name = bundle_name )
101+ if not _is_safe_bundle_name (bundle_name ):
102+ print_warning (f"Skipping `{ name } `: unsafe bundle name `{ bundle_name } `." )
103+ return None
104+
105+ parts = name .split ("/" , 1 )[- 1 ].split ("." )
106+ if len (parts ) != 3 :
107+ print_warning (f"Skipping `{ name } `: expected a `catalog.schema.name` skill name." )
108+ return None
109+ catalog , schema , securable_name = parts
110+ return SkillRef (
111+ catalog = catalog , schema = schema , securable_name = securable_name , bundle_name = bundle_name
112+ )
90113
91114
92115def list_schema_skills (
@@ -241,20 +264,18 @@ def existing_skill_on_disk(roots: list[Path], bundle_name: str) -> bool:
241264 return any ((root / bundle_name ).exists () for root in roots )
242265
243266
244- def should_download_skill (roots : list [Path ], ref : SkillRef , * , location : str ) -> bool :
267+ def should_download_skill (roots : list [Path ], ref : SkillRef ) -> bool :
245268 """Whether ``ref`` should be fetched and written into ``roots``.
246269
247270 Applies the disk-only check that needs no bundle bytes: prompts before
248- overwriting a skill already on disk (``location`` is the source
249- ``<catalog>.<schema>`` shown in that prompt), so a declined skill is never
250- fetched. Dedup keys on the bundle name, since that is the directory an agent
251- would load. Name validity is the server's job -- FinalizeSkill enforces the
252- Agent Skills naming rules on ``bundle_name`` before we ever see it -- so
253- ucode does not re-check it here.
271+ overwriting a skill already on disk (naming the source by ``ref.fqn``), so a
272+ declined skill is never fetched. Dedup keys on the bundle name, since that is
273+ the directory an agent would load. Name validity is the server's job --
274+ FinalizeSkill enforces the Agent Skills naming rules on ``bundle_name`` before
275+ we ever see it -- so ucode does not re-check it here.
254276 """
255277 if existing_skill_on_disk (roots , ref .bundle_name ) and not prompt_yes_no (
256- f"A skill named `{ ref .bundle_name } ` already exists. "
257- f"Overwrite it with `{ location } .{ ref .securable_name } `?"
278+ f"A skill named `{ ref .bundle_name } ` already exists. Overwrite it with `{ ref .fqn } `?"
258279 ):
259280 print_note (f"Kept existing `{ ref .bundle_name } `." )
260281 return False
@@ -276,23 +297,25 @@ def write_skill(roots: list[Path], ref: SkillRef, files: dict[str, bytes]) -> No
276297
277298
278299def _fetch_bundles (
279- workspace : str , token : str , catalog : str , schema : str , refs : list [ SkillRef ]
300+ workspace : str , token : str , refs : list [ SkillRef ], * , label : str
280301) -> dict [str , tuple [dict [str , bytes ] | None , str | None ]]:
281- """Fetch every skill's bundle concurrently, keyed by securable leaf .
302+ """Fetch every skill's bundle concurrently, keyed by FQN .
282303
283- Renders a ``k/n`` progress bar that advances as each fetch completes.
304+ Renders a ``k/n`` progress bar labeled ``label`` that advances as each fetch
305+ completes. Keying on the FQN keeps a cross-schema batch's securables apart,
306+ since a securable name is unique only within its own schema.
284307 """
285308 if not refs :
286309 return {}
287310 results : dict [str , tuple [dict [str , bytes ] | None , str | None ]] = {}
288311 with (
289- progress_bar (f"Fetching skills from { catalog } . { schema } " , len (refs )) as advance ,
312+ progress_bar (label , len (refs )) as advance ,
290313 ThreadPoolExecutor (max_workers = min (_MAX_FETCH_WORKERS , len (refs ))) as pool ,
291314 ):
292315 futures = {
293316 pool .submit (
294- fetch_skill_bundle , workspace , token , catalog , schema , ref .securable_name
295- ): ref .securable_name
317+ fetch_skill_bundle , workspace , token , ref . catalog , ref . schema , ref .securable_name
318+ ): ref .fqn
296319 for ref in refs
297320 }
298321 for future in as_completed (futures ):
@@ -301,32 +324,58 @@ def _fetch_bundles(
301324 return results
302325
303326
304- def _reject_bundle_name_collisions (refs : list [SkillRef ], * , location : str ) -> list [SkillRef ]:
327+ def _reject_bundle_name_collisions (refs : list [SkillRef ]) -> list [SkillRef ]:
305328 """``refs`` with any later skill that repeats an earlier one's bundle name dropped.
306329
307330 Only the securable name is unique within a schema; ``bundle_name`` comes from
308331 each bundle's SKILL.md frontmatter and is never checked against its siblings,
309- so one schema can hold two skills claiming the same directory. Writing both
310- would land them on top of each other, leaving whichever finished last with no
311- sign the other was lost, so keep the first and warn about the rest.
332+ so two skills can claim the same directory. Writing both would land them on top
333+ of each other, leaving whichever finished last with no sign the other was lost,
334+ so keep the first and warn about the rest by FQN .
312335 """
313336 kept : list [SkillRef ] = []
314- claimed : dict [str , str ] = {}
337+ claimed : dict [str , SkillRef ] = {}
315338 for ref in refs :
316339 winner = claimed .get (ref .bundle_name )
317340 if winner is not None :
318341 print_warning (
319- f"Skipping `{ location } .{ ref .securable_name } `: its bundle name "
320- f"`{ ref .bundle_name } ` is already claimed by `{ location } .{ winner } `. "
321- "Rename one skill's SKILL.md `name:` to download both."
342+ f"Skipping `{ ref .fqn } `: its bundle name `{ ref .bundle_name } ` is already "
343+ f"claimed by `{ winner .fqn } `. Rename one skill's SKILL.md `name:` to download both."
322344 )
323345 continue
324- claimed [ref .bundle_name ] = ref . securable_name
346+ claimed [ref .bundle_name ] = ref
325347 kept .append (ref )
326348 return kept
327349
328350
329- def download_skills (
351+ def _download_refs (
352+ workspace : str , token : str , refs : list [SkillRef ], roots : list [Path ], * , label : str
353+ ) -> tuple [int , int ]:
354+ """Fetch and write ``refs`` into ``roots``, returning ``(written, total)``.
355+
356+ The shared download core: drop siblings claiming one directory
357+ (``_reject_bundle_name_collisions``), prompt before overwriting a skill already
358+ on disk (``should_download_skill``, so a declined skill is never fetched), then
359+ fetch the survivors' bundles concurrently and write them. ``total`` is the
360+ count that could reach disk (dropped siblings excluded), so a caller's summary
361+ denominator is right. A per-skill fetch failure warns and skips only that skill.
362+ """
363+ refs = _reject_bundle_name_collisions (refs )
364+ to_download = [ref for ref in refs if should_download_skill (roots , ref )]
365+ bundles = _fetch_bundles (workspace , token , to_download , label = label )
366+ written = 0
367+ for ref in to_download :
368+ files , reason = bundles [ref .fqn ]
369+ if reason or files is None :
370+ print_warning (f"Skipping `{ ref .fqn } `: { reason } ." )
371+ continue
372+ write_skill (roots , ref , files )
373+ written += 1
374+ console .print ()
375+ return written , len (refs )
376+
377+
378+ def download_skills_from_schema_locations (
330379 workspace : str ,
331380 token : str ,
332381 locations : list [str ],
@@ -335,22 +384,13 @@ def download_skills(
335384) -> None :
336385 """Download every skill in each ``<catalog>.<schema>`` location to disk.
337386
338- Locations are processed one at a time, and each runs three stages:
339-
340- 1. **List** the schema's finalized skills. When ``skills`` is given, restrict
341- to those securable names (the name that identifies a skill in UC); names
342- absent from the schema warn and are skipped, and ``None`` keeps the whole
343- schema. Siblings claiming one directory are then reduced to the first (see
344- ``_reject_bundle_name_collisions``).
345- 2. **Decide** which to download via ``should_download_skill`` (prompts before
346- overwriting a skill already on disk), so a declined skill is never fetched.
347- 3. **Fetch** the survivors' bundles concurrently (with a progress bar) and
348- **write** them.
349-
350- Finishing one location before starting the next means a skill written for an
351- earlier location is already on disk when a same-named skill in a later
352- location reaches its decide stage, so the overwrite prompt still fires. A
353- failure on one skill warns and skips it without aborting the batch.
387+ Locations are processed one at a time. Each lists the schema's finalized
388+ skills, applies the optional ``skills`` filter (securable names -- the name
389+ that identifies a skill in UC; unknown ones warn, ``None`` keeps the whole
390+ schema), then hands the refs to ``_download_refs`` and prints a per-location
391+ summary. Finishing one location before the next means a skill written for an
392+ earlier location is already on disk when a same-named skill in a later location
393+ reaches the overwrite prompt, so the prompt still fires.
354394 """
355395 roots = skill_dir_roots (path )
356396 roots_display = " and " .join (str (root ) for root in roots )
@@ -374,28 +414,50 @@ def download_skills(
374414 if not refs :
375415 print_note (f"No skills found in `{ location } `." )
376416 continue
377- # Before the decide stage, so a dropped sibling is never fetched and the
378- # summary's denominator counts only skills that can reach disk.
379- refs = _reject_bundle_name_collisions (refs , location = location )
380-
381- to_download = [ref for ref in refs if should_download_skill (roots , ref , location = location )]
382- bundles = _fetch_bundles (workspace , token , catalog , schema , to_download )
383- written = 0
384- for ref in to_download :
385- files , reason = bundles [ref .securable_name ]
386- if reason or files is None :
387- print_warning (f"Skipping `{ location } .{ ref .securable_name } `: { reason } ." )
388- continue
389- write_skill (roots , ref , files )
390- written += 1
391- console .print ()
392- total = len (refs )
417+ written , total = _download_refs (
418+ workspace , token , refs , roots , label = f"Fetching skills from { location } "
419+ )
393420 skipped = f"; { total - written } skipped" if written < total else ""
394421 print_success (
395422 f"Downloaded { written } /{ total } skill(s){ skipped } from `{ location } ` in { roots_display } ."
396423 )
397424
398425
426+ def get_skill (workspace : str , token : str , fqn : str ) -> SkillRef | None :
427+ """The finalized skill named by ``fqn``, or None if it cannot be downloaded.
428+
429+ ``GetSkill`` returns the same shape as a ``ListSkills`` entry, so the response
430+ runs through ``_skill_ref``; a missing, unfinalized, or malformed skill is None.
431+ """
432+ hostname = workspace_hostname (workspace )
433+ payload , _ = _http_get_json (
434+ f"https://{ hostname } /api/2.1/unity-catalog/skills/{ fqn } " , token , timeout = 30
435+ )
436+ return _skill_ref (payload ) if isinstance (payload , dict ) else None
437+
438+
439+ def download_selected_skills (workspace : str , token : str , fqns : list [str ], path : str | None ) -> None :
440+ """Download the skills named by ``fqns`` (``<catalog>.<schema>.<name>``) to disk.
441+
442+ Resolves each FQN with ``GetSkill`` (a skill that cannot be downloaded warns and
443+ is skipped), then hands the flat, possibly cross-schema set to ``_download_refs``
444+ in one pass, so collisions are deduped across the whole selection under a single
445+ summary.
446+ """
447+ roots = skill_dir_roots (path )
448+ roots_display = " and " .join (str (root ) for root in roots )
449+ refs : list [SkillRef ] = []
450+ for fqn in fqns :
451+ ref = get_skill (workspace , token , fqn )
452+ if ref is None :
453+ print_warning (f"Skipping `{ fqn } `: not a downloadable skill." )
454+ continue
455+ refs .append (ref )
456+ written , total = _download_refs (workspace , token , refs , roots , label = "Fetching selected skills" )
457+ skipped = f"; { total - written } skipped" if written < total else ""
458+ print_success (f"Downloaded { written } /{ total } skill(s){ skipped } in { roots_display } ." )
459+
460+
399461def download_managed_skills_on_launch (
400462 workspace : str , token : str , locations : list [str ], path : str | None = None
401463) -> list [str ]:
@@ -418,15 +480,17 @@ def download_managed_skills_on_launch(
418480 if reason :
419481 print_warning (f"Could not list workspace skills in `{ location } `: { reason } ." )
420482 continue
421- refs = _reject_bundle_name_collisions (refs , location = location )
483+ refs = _reject_bundle_name_collisions (refs )
422484 missing = [ref for ref in refs if not existing_skill_on_disk (roots , ref .bundle_name )]
423485 if not missing :
424486 continue
425- bundles = _fetch_bundles (workspace , token , catalog , schema , missing )
487+ bundles = _fetch_bundles (
488+ workspace , token , missing , label = f"Fetching skills from { location } "
489+ )
426490 for ref in missing :
427- files , reason = bundles [ref .securable_name ]
491+ files , reason = bundles [ref .fqn ]
428492 if reason or files is None :
429- print_warning (f"Skipping `{ location } . { ref .securable_name } `: { reason } ." )
493+ print_warning (f"Skipping `{ ref .fqn } `: { reason } ." )
430494 continue
431495 write_skill (roots , ref , files )
432496 written .append (ref .bundle_name )
@@ -441,12 +505,12 @@ def configure_skills_download_command(
441505 Downloads to ``path`` (or the home dir when None), then registers/keeps the
442506 schema-less MCP connection. ``skill_locations`` is never touched, so a prior
443507 ``--mcp`` set survives a download run. ``skills`` narrows the download (see
444- ``download_skills ``)."""
508+ ``download_skills_from_schema_locations ``)."""
445509 state = load_state ()
446510 workspace , profile , clients = setup_mcp_clients (state , "Skills" )
447511 token = get_databricks_token (workspace , profile )
448512
449- download_skills (workspace , token , locations , path , skills )
513+ download_skills_from_schema_locations (workspace , token , locations , path , skills )
450514
451515 register_schemaless_skills_connection (state , workspace , profile , clients )
452516 return 0
0 commit comments