@@ -152,6 +152,13 @@ class LocalWorkspaceFS(BaseWorkspaceFS):
152152 def __init__ (self , read_only_staged_skill : bool = False ):
153153 self .read_only_staged_skill = read_only_staged_skill
154154
155+ @staticmethod
156+ def _normalize_stage_mode (mode : str ) -> str :
157+ mode = (mode or "copy" ).strip ().lower ()
158+ if mode not in {"copy" , "link" }:
159+ raise ValueError (f"unsupported local workspace stage mode: { mode !r} " )
160+ return mode
161+
155162 @override
156163 async def put_files (
157164 self ,
@@ -189,11 +196,15 @@ async def stage_directory(
189196 to: Destination path relative to workspace
190197 opt: Staging options
191198 """
192- self ._put_directory (ws , src , dst )
199+ mode = self ._normalize_stage_mode (opt .mode )
200+ self ._put_directory (ws , src , dst , mode = mode )
193201
194202 # Make tree read-only if requested
195203 ro = opt .read_only or self .read_only_staged_skill
196- if ro :
204+ # Link mode uses symlinks to host files. chmod would follow symlinks on
205+ # many platforms and mutate the source skill tree, so leave protection
206+ # to the skill stager's symlink-aware chmod pass.
207+ if ro and mode != "link" :
197208 if dst :
198209 dst = Path (ws .path ) / Path (dst )
199210 else :
@@ -205,9 +216,10 @@ def _put_directory(
205216 ws : WorkspaceInfo ,
206217 src : str ,
207218 dst : str ,
219+ mode : str = "copy" ,
208220 ) -> None :
209221 """
210- Copy an entire directory from host into workspace.
222+ Put a host path into workspace using copy or link mode .
211223
212224 Args:
213225 ctx: Context for the operation
@@ -217,31 +229,44 @@ def _put_directory(
217229 """
218230 src = os .path .abspath (src )
219231 dst = path_join (ws .path , dst )
220- self ._copy_directory (src , dst )
232+ src_path = Path (src )
233+ if mode == "link" and src_path .is_dir ():
234+ self ._link_directory (src , dst )
235+ elif mode == "link" :
236+ make_symlink (ws .path , os .path .relpath (dst , ws .path ), src )
237+ else :
238+ copy_path (src , dst )
221239
222- def _copy_directory (
240+ def _link_directory (
223241 self ,
224242 src : str ,
225243 dst : str ,
226244 ) -> None :
227- """Copy directory recursively .
245+ """Stage a directory by symlinking its direct children .
228246
229- Args:
230- src: Source directory path
231- dst: Destination directory path
247+ The destination root is a real directory so later staging logic can add
248+ workspace-local links such as ``out`` / ``work`` without mutating the
249+ original skill directory. Direct child directories such as ``scripts``
250+ or ``references`` are linked as directories to avoid walking large
251+ skill trees.
232252 """
233253 src_path = Path (src )
234254 dst_path = Path (dst )
255+ if dst_path .exists () or dst_path .is_symlink ():
256+ if dst_path .is_symlink () or dst_path .is_file ():
257+ dst_path .unlink ()
258+ elif not dst_path .is_dir ():
259+ raise ValueError (f"cannot stage into non-directory path: { dst } " )
235260 dst_path .mkdir (parents = True , exist_ok = True )
236261
237- for item in src_path .rglob ( "*" ):
238- rel_path = item .relative_to ( src )
239- target = dst_path / rel_path
240- if item .is_dir ():
241- target . mkdir ( parents = True , exist_ok = True )
242- else :
243- target .parent . mkdir ( parents = True , exist_ok = True )
244- shutil . copy2 (item , target )
262+ for item in src_path .iterdir ( ):
263+ target = dst_path / item .name
264+ if target . exists () or target . is_symlink ():
265+ if target .is_dir () and not target . is_symlink ():
266+ shutil . rmtree ( target )
267+ else :
268+ target .unlink ( )
269+ target . symlink_to (item . resolve ( strict = False ), target_is_directory = item . is_dir () )
245270
246271 def _make_tree_read_only (
247272 self ,
@@ -401,31 +426,20 @@ async def stage_inputs(
401426 # Handle host inputs
402427 host_path = spec .src [len ("host://" ):]
403428 resolved = host_path
404- if mode == "link" :
405- make_symlink (ws .path , dst .as_posix (), host_path )
406- else :
407- self ._put_directory (ws , host_path , dst .parent .as_posix ())
429+ self ._put_directory (ws , host_path , dst .as_posix (), mode = mode )
408430 elif spec .src .startswith ("workspace://" ):
409431 # Handle workspace inputs
410432 rel = spec .src [len ("workspace://" ):]
411433 src = path_join (ws .path , rel )
412434 resolved = rel
413-
414- if mode == "link" :
415- make_symlink (ws .path , dst .as_posix (), src )
416- else :
417- copy_path (src , path_join (ws .path , dst .as_posix ()))
435+ self ._put_directory (ws , src , dst .as_posix (), mode = mode )
418436 elif spec .src .startswith ("skill://" ):
419437 # Handle skill inputs
420438 rest = spec .src [len ("skill://" ):]
421439 src_base = Path (ws .path ) / DIR_SKILLS
422440 src = path_join (src_base .as_posix (), rest )
423441 resolved = src
424-
425- if mode == "link" :
426- make_symlink (ws .path , dst .as_posix (), src )
427- else :
428- copy_path (src , path_join (ws .path , dst .as_posix ()))
442+ self ._put_directory (ws , src , dst .as_posix (), mode = mode )
429443 else :
430444 raise ValueError (f"unsupported input: { spec .src } " )
431445
0 commit comments