@@ -2,12 +2,16 @@ package skills
22
33import (
44 "fmt"
5+ "io"
56 "os"
67 "os/exec"
78 "path/filepath"
89 "runtime"
910)
1011
12+ // stderrWriter is the target for diagnostic output. Replaced in tests.
13+ var stderrWriter io.Writer = os .Stderr
14+
1115// CreateSkillLink installs skillName from sourceSkillDir into agentSkillsDir.
1216// useCopy=true copies files recursively; useCopy=false creates a symlink.
1317// The operation is idempotent: a correct existing symlink or copy is left unchanged.
@@ -21,15 +25,17 @@ func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string, useCopy b
2125 info , err := os .Lstat (linkPath )
2226 if err == nil {
2327 if info .Mode ()& os .ModeSymlink != 0 {
24- if isSymlinkCorrect (linkPath , agentSkillsDir , sourceSkillDir ) {
28+ // For useCopy=false: skip if already pointing to the right place.
29+ // For useCopy=true: remove the symlink so we can replace it with a copy.
30+ if ! useCopy && isSymlinkCorrect (linkPath , sourceSkillDir ) {
2531 return nil
2632 }
2733 if rmErr := os .Remove (linkPath ); rmErr != nil {
2834 return fmt .Errorf ("remove existing symlink %s: %w" , linkPath , rmErr )
2935 }
3036 } else if info .IsDir () {
3137 if ! useCopy {
32- fmt .Fprintf (os . Stderr ,
38+ fmt .Fprintf (stderrWriter ,
3339 "warning: %s is a copied directory; remove it manually to switch to symlink mode\n " ,
3440 linkPath )
3541 return nil
@@ -49,19 +55,17 @@ func CreateSkillLink(sourceSkillDir, agentSkillsDir, skillName string, useCopy b
4955}
5056
5157// isSymlinkCorrect returns true if linkPath is a non-broken symlink resolving to sourceSkillDir.
52- func isSymlinkCorrect (linkPath , agentSkillsDir , sourceSkillDir string ) bool {
53- target , err := os .Readlink (linkPath )
58+ // os.SameFile is used instead of string comparison to handle case-insensitive filesystems (e.g. macOS APFS).
59+ func isSymlinkCorrect (linkPath , sourceSkillDir string ) bool {
60+ linkInfo , err := os .Stat (linkPath )
5461 if err != nil {
55- return false
56- }
57- if ! filepath .IsAbs (target ) {
58- target = filepath .Join (agentSkillsDir , target )
62+ return false // broken symlink
5963 }
60- if filepath .Clean (target ) != filepath .Clean (sourceSkillDir ) {
64+ srcInfo , err := os .Stat (sourceSkillDir )
65+ if err != nil {
6166 return false
6267 }
63- _ , err = os .Stat (linkPath )
64- return err == nil
68+ return os .SameFile (linkInfo , srcInfo )
6569}
6670
6771// createSymlink creates a symlink at linkPath pointing to sourceSkillDir.
@@ -83,7 +87,7 @@ func createSymlink(sourceSkillDir, agentSkillsDir, linkPath string) error {
8387 if err := exec .Command ("cmd" , "/C" , "mklink" , "/J" , linkPath , sourceSkillDir ).Run (); err == nil {
8488 return nil
8589 }
86- fmt .Fprintf (os . Stderr , "warning: symlink and junction unavailable; copying %s to %s\n " , sourceSkillDir , linkPath )
90+ fmt .Fprintf (stderrWriter , "warning: symlink and junction unavailable; copying %s to %s\n " , sourceSkillDir , linkPath )
8791 return copyDir (sourceSkillDir , linkPath )
8892}
8993
@@ -95,25 +99,31 @@ func copyDir(src, dst string) error {
9599 if err != nil {
96100 return fmt .Errorf ("create temp copy dir: %w" , err )
97101 }
102+ // Always clean up the temp dir so it is never left as an orphan in agentSkillsDir.
103+ tmpRemoved := false
104+ defer func () {
105+ if ! tmpRemoved {
106+ _ = os .RemoveAll (tmpDst )
107+ }
108+ }()
109+
98110 if err := mergeDir (src , tmpDst ); err != nil {
99- _ = os .RemoveAll (tmpDst )
100111 return err
101112 }
102113 if err := os .RemoveAll (dst ); err != nil {
103- _ = os .RemoveAll (tmpDst )
104114 return fmt .Errorf ("remove stale copy dir: %w" , err )
105115 }
106116 if err := os .Rename (tmpDst , dst ); err != nil {
107117 // Cross-filesystem fallback: re-create dst from the temp copy.
118+ fmt .Fprintf (stderrWriter , "warning: rename %s → %s failed (%v); falling back to copy\n " , tmpDst , dst , err )
108119 if mkErr := os .MkdirAll (dst , 0o755 ); mkErr != nil {
109- _ = os .RemoveAll (tmpDst )
110120 return fmt .Errorf ("create copy dir: %w" , mkErr )
111121 }
112122 if mergeErr := mergeDir (tmpDst , dst ); mergeErr != nil {
113- _ = os .RemoveAll (tmpDst )
114123 return mergeErr
115124 }
116- _ = os .RemoveAll (tmpDst )
125+ } else {
126+ tmpRemoved = true // rename succeeded; temp dir is now dst
117127 }
118128 return nil
119129}
@@ -151,20 +161,18 @@ func CheckSkillLink(agentSkillsDir, skillName, expectedSourceDir string) string
151161 return "copy"
152162 }
153163
154- // It's a symlink. Verify the target exists.
155- if _ , err := os .Stat (linkPath ); err != nil {
164+ // It's a symlink. Verify the target exists by following the link.
165+ resolvedInfo , err := os .Stat (linkPath )
166+ if err != nil {
156167 return "broken"
157168 }
158169
159- // Target exists. Check if it points to the expected place .
160- target , err := os .Readlink ( linkPath )
170+ // Use os.SameFile to handle case-insensitive filesystems (e.g. macOS APFS) .
171+ srcInfo , err := os .Stat ( expectedSourceDir )
161172 if err != nil {
162- return "broken"
163- }
164- if ! filepath .IsAbs (target ) {
165- target = filepath .Join (agentSkillsDir , target )
173+ return "wrong_target"
166174 }
167- if filepath . Clean ( target ) == filepath . Clean ( expectedSourceDir ) {
175+ if os . SameFile ( resolvedInfo , srcInfo ) {
168176 return "ok"
169177 }
170178 return "wrong_target"
0 commit comments