55 "errors"
66 "fmt"
77 "io"
8+ "io/fs"
89 "net/http"
910 "os"
1011 "path/filepath"
@@ -28,6 +29,10 @@ const (
2829 defaultSkillsRepoRef = "v0.1.4"
2930)
3031
32+ // managedSkillMarker is written at the root of each materialized (non-symlink)
33+ // skill install under an agent directory so uninstall can remove it safely.
34+ const managedSkillMarker = ".databricks-skill-managed"
35+
3136// fetchFileFn is the function used to download individual skill files.
3237// It is a package-level var so tests can replace it with a mock.
3338var fetchFileFn = fetchSkillFile
@@ -78,15 +83,18 @@ func FetchManifest(ctx context.Context) (*Manifest, error) {
7883// to disk and adjusts the download URL accordingly.
7984const sharedFilePrefix = "@root:"
8085
81- func fetchSkillFile ( ctx context. Context , ref , skillName , filePath string ) ([] byte , error ) {
82- var url string
86+ // skillFileRawURL returns the raw.githubusercontent.com URL for a manifest file entry.
87+ func skillFileRawURL ( ref , skillName , filePath string ) string {
8388 if rootPath , ok := strings .CutPrefix (filePath , sharedFilePrefix ); ok {
84- url = fmt .Sprintf ("https://raw.githubusercontent.com/%s/%s/%s/%s" ,
89+ return fmt .Sprintf ("https://raw.githubusercontent.com/%s/%s/%s/%s" ,
8590 skillsRepoOwner , skillsRepoName , ref , rootPath )
86- } else {
87- url = fmt .Sprintf ("https://raw.githubusercontent.com/%s/%s/%s/%s/%s/%s" ,
88- skillsRepoOwner , skillsRepoName , ref , skillsRepoPath , skillName , filePath )
8991 }
92+ return fmt .Sprintf ("https://raw.githubusercontent.com/%s/%s/%s/%s/%s/%s" ,
93+ skillsRepoOwner , skillsRepoName , ref , skillsRepoPath , skillName , filePath )
94+ }
95+
96+ func fetchSkillFile (ctx context.Context , ref , skillName , filePath string ) ([]byte , error ) {
97+ url := skillFileRawURL (ref , skillName , filePath )
9098
9199 req , err := http .NewRequestWithContext (ctx , http .MethodGet , url , nil )
92100 if err != nil {
@@ -401,32 +409,14 @@ type installParams struct {
401409 ref string
402410}
403411
404- // agentMetadataDirs lists subdirectory prefixes that are agent-specific UI
405- // metadata (marketplace icons, display names). Only agents with
406- // InstallAgentMetadata=true receive these during a copy install.
407- var agentMetadataDirs = []string {"agents" , "assets" }
408-
409- // isAgentMetadataPath reports whether rel (forward-slash separated) falls
410- // under one of the agent metadata directories.
411- func isAgentMetadataPath (rel string ) bool {
412- normalized := filepath .ToSlash (rel )
413- for _ , dir := range agentMetadataDirs {
414- if normalized == dir || strings .HasPrefix (normalized , dir + "/" ) {
415- return true
416- }
417- }
418- return false
419- }
420-
421412func installSkillForAgents (ctx context.Context , skillName string , files []string , detectedAgents []* agents.Agent , params installParams ) error {
422413 canonicalDir := filepath .Join (params .baseDir , skillName )
423414 if err := installSkillToDir (ctx , params .ref , skillName , canonicalDir , files ); err != nil {
424415 return err
425416 }
426417
427418 // For project scope, always symlink. For global, symlink when multiple agents.
428- // Agents with PreferSkillCopy always get a full directory copy so
429- // marketplace metadata under agents/ and assets/ stays under their config dir.
419+ // Symlinks point at the canonical tree (full manifest layout for every agent).
430420 symlinkEligible := params .scope == ScopeProject || len (detectedAgents ) > 1
431421
432422 for _ , agent := range detectedAgents {
@@ -443,7 +433,7 @@ func installSkillForAgents(ctx context.Context, skillName string, files []string
443433 continue
444434 }
445435
446- useSymlink := symlinkEligible && ! agent . PreferSkillCopy
436+ useSymlink := symlinkEligible
447437 if useSymlink {
448438 symlinkTarget := canonicalDir
449439 // For project scope, use relative symlinks so they work for teammates.
@@ -455,17 +445,14 @@ func installSkillForAgents(ctx context.Context, skillName string, files []string
455445 }
456446 if err := createSymlink (symlinkTarget , destDir ); err != nil {
457447 log .Debugf (ctx , "Symlink failed for %s, copying instead: %v" , agent .DisplayName , err )
458- if err := copyDir (canonicalDir , destDir ); err != nil {
448+ if err := copySkillMaterialized (canonicalDir , destDir ); err != nil {
459449 log .Warnf (ctx , "Failed to install for %s: %v" , agent .DisplayName , err )
460450 continue
461451 }
462452 }
463453 log .Debugf (ctx , "Installed %q for %s (symlinked)" , skillName , agent .DisplayName )
464454 } else {
465- // Copy from canonical dir. Skip agent metadata dirs for agents
466- // that don't consume them.
467- skipMetadata := ! agent .InstallAgentMetadata
468- if err := copyDirFiltered (canonicalDir , destDir , skipMetadata ); err != nil {
455+ if err := copySkillMaterialized (canonicalDir , destDir ); err != nil {
469456 log .Warnf (ctx , "Failed to install for %s: %v" , agent .DisplayName , err )
470457 continue
471458 }
@@ -509,6 +496,13 @@ func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName
509496 }
510497 }
511498
499+ // Prior materialized install from this CLI; copySkillMaterialized will replace it.
500+ if fi .IsDir () {
501+ if _ , err := os .Stat (filepath .Join (destDir , managedSkillMarker )); err == nil {
502+ return nil
503+ }
504+ }
505+
512506 backupDir , err := os .MkdirTemp ("" , fmt .Sprintf ("databricks-skill-backup-%s-*" , skillName ))
513507 if err != nil {
514508 return fmt .Errorf ("failed to create backup directory: %w" , err )
@@ -542,9 +536,15 @@ func installSkillToDir(ctx context.Context, ref, skillName, destDir string, file
542536 // Strip the @root: prefix so shared assets land at a local path
543537 // (e.g. "@root:assets/databricks.svg" → "assets/databricks.svg").
544538 if rootPath , ok := strings .CutPrefix (file , sharedFilePrefix ); ok {
539+ if rootPath == "" {
540+ return fmt .Errorf ("invalid manifest file entry: empty path after %q" , sharedFilePrefix )
541+ }
545542 file = rootPath
546543 }
547- destPath := filepath .Join (destDir , file )
544+ destPath , err := safeSkillDestPath (destDir , file )
545+ if err != nil {
546+ return err
547+ }
548548
549549 if err := os .MkdirAll (filepath .Dir (destPath ), 0o755 ); err != nil {
550550 return fmt .Errorf ("failed to create directory: %w" , err )
@@ -559,20 +559,35 @@ func installSkillToDir(ctx context.Context, ref, skillName, destDir string, file
559559 return nil
560560}
561561
562- // copyDir copies all files from src to dest, recreating the directory structure.
563- func copyDir (src , dest string ) error {
564- return copyDirFiltered (src , dest , false )
562+ // safeSkillDestPath joins destDir with a manifest-relative path and rejects escapes.
563+ func safeSkillDestPath (destDir , file string ) (string , error ) {
564+ if file == "" {
565+ return "" , errors .New ("invalid manifest file entry: empty path" )
566+ }
567+ destPath := filepath .Join (destDir , file )
568+ base := filepath .Clean (destDir )
569+ cleaned := filepath .Clean (destPath )
570+ rel , err := filepath .Rel (base , cleaned )
571+ if err != nil {
572+ return "" , fmt .Errorf ("invalid skill file path %q: %w" , file , err )
573+ }
574+ if rel == "." {
575+ return "" , fmt .Errorf ("invalid skill file path %q: resolves to skill root" , file )
576+ }
577+ if ! filepath .IsLocal (rel ) {
578+ return "" , fmt .Errorf ("skill file path %q escapes destination directory" , file )
579+ }
580+ return destPath , nil
565581}
566582
567- // copyDirFiltered copies files from src to dest. When skipAgentMetadata is
568- // true, subdirectories matching agentMetadataDirs (agents/, assets/) are
569- // skipped so non-Codex agents don't receive marketplace UI files.
570- func copyDirFiltered (src , dest string , skipAgentMetadata bool ) error {
583+ // copySkillMaterialized copies the full skill tree from src to dest and writes
584+ // managedSkillMarker so uninstall can remove the directory safely.
585+ func copySkillMaterialized (src , dest string ) error {
571586 if err := os .RemoveAll (dest ); err != nil {
572587 return fmt .Errorf ("failed to remove existing path: %w" , err )
573588 }
574589
575- return filepath .Walk (src , func (path string , info os. FileInfo , err error ) error {
590+ err := filepath .WalkDir (src , func (path string , d fs. DirEntry , err error ) error {
576591 if err != nil {
577592 return err
578593 }
@@ -582,16 +597,9 @@ func copyDirFiltered(src, dest string, skipAgentMetadata bool) error {
582597 return err
583598 }
584599
585- if skipAgentMetadata && rel != "." && isAgentMetadataPath (rel ) {
586- if info .IsDir () {
587- return filepath .SkipDir
588- }
589- return nil
590- }
591-
592600 target := filepath .Join (dest , rel )
593601
594- if info .IsDir () {
602+ if d .IsDir () {
595603 return os .MkdirAll (target , 0o755 )
596604 }
597605
@@ -604,6 +612,10 @@ func copyDirFiltered(src, dest string, skipAgentMetadata bool) error {
604612 }
605613 return os .WriteFile (target , data , 0o644 )
606614 })
615+ if err != nil {
616+ return err
617+ }
618+ return os .WriteFile (filepath .Join (dest , managedSkillMarker ), nil , 0o644 )
607619}
608620
609621func createSymlink (source , dest string ) error {
0 commit comments