Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 73 additions & 6 deletions internal/commands/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,16 +333,16 @@ func RefreshSkillsIfVersionChanged() bool {
return false
}

refreshed := false
needsRefresh := baselineSkillInstalled()
if needsRefresh {
if _, err := installSkillFiles(); err == nil {
refreshed = true
}
refreshed := refreshAllInstalledSkills()

// Repair Claude symlink if broken (e.g. baseline dir was recreated)
if harness.DetectClaude() {
repairClaudeSkillLink()
}

// Update sentinel only when no refresh was needed or it succeeded.
// On transient failure, leave the sentinel stale so the next run retries.
needsRefresh := baselineSkillInstalled()
if !needsRefresh || refreshed {
_ = os.MkdirAll(filepath.Dir(sentinelPath), 0o755) //nolint:gosec // G301: config dir
_ = os.WriteFile(sentinelPath, []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret
Expand All @@ -351,6 +351,73 @@ func RefreshSkillsIfVersionChanged() bool {
return refreshed
}

func refreshAllInstalledSkills() bool {
embedded, err := skills.FS.ReadFile("basecamp/SKILL.md")
if err != nil {
return false
}

updated := 0
failed := 0
for _, loc := range skillLocations {
// Skip project-relative paths — no reliable project root in PostRunE.
if !strings.HasPrefix(loc.Path, "~") && !filepath.IsAbs(loc.Path) {
continue
}

expanded := expandSkillPath(loc.Path)
if _, statErr := os.Stat(expanded); statErr != nil {
if !os.IsNotExist(statErr) {
failed++ // permission or IO error on a known location
}
continue
}

if writeErr := os.WriteFile(expanded, embedded, 0o644); writeErr == nil { //nolint:gosec // G306: Skill files are not secrets
updated++
} else {
failed++
}
}

// Stamp installed version in the baseline directory only on full success.
if failed == 0 && updated > 0 {
if home, err := os.UserHomeDir(); err == nil {
baselineDir := filepath.Join(home, ".agents", "skills", "basecamp")
_ = os.WriteFile(filepath.Join(baselineDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret
}
}

return updated > 0 && failed == 0
}

// repairClaudeSkillLink repairs a broken symlink at ~/.claude/skills/basecamp.
// If the path is a directory (copy fallback), the file refresh already handled it.
func repairClaudeSkillLink() {
home, err := os.UserHomeDir()
if err != nil {
return
}

symlinkPath := filepath.Join(home, ".claude", "skills", "basecamp")
info, err := os.Lstat(symlinkPath)
if err != nil {
return // doesn't exist, nothing to repair
}

if info.Mode()&os.ModeSymlink == 0 {
return // not a symlink (directory copy fallback), file refresh handled it
}

// It's a symlink — check if the target is reachable
if _, statErr := os.Stat(symlinkPath); statErr == nil {
return // symlink is healthy
}

// Broken symlink — repair it
_, _, _ = linkSkillToClaude()
}

func copySkillFiles(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets
return err
Expand Down
151 changes: 150 additions & 1 deletion internal/commands/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ func TestRefreshSkillsIfVersionChanged_NoSentinelUpdateOnFailure(t *testing.T) {
defer func() { version.Version = origVersion }()

// Install baseline skill, then make the skill file read-only
// so installSkillFiles() will fail on write
// so refreshAllInstalledSkills() will fail on write
_, err := installSkillFiles()
require.NoError(t, err)

Expand All @@ -472,3 +472,152 @@ func TestRefreshSkillsIfVersionChanged_NoSentinelUpdateOnFailure(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "2.0.0", string(sentinel), "sentinel should remain unchanged on failure")
}

func TestRefreshAllInstalledSkills_MultipleLocations(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", home) // no claude binary

origVersion := version.Version
version.Version = "5.0.0"
defer func() { version.Version = origVersion }()

embedded, err := skills.FS.ReadFile("basecamp/SKILL.md")
require.NoError(t, err)

// Pre-install skill at baseline and Claude global locations
baseline := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(baseline, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(baseline, "SKILL.md"), []byte("old"), 0o644))

claudeSkill := filepath.Join(home, ".claude", "skills", "basecamp")
require.NoError(t, os.MkdirAll(claudeSkill, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(claudeSkill, "SKILL.md"), []byte("old"), 0o644))

opencode := filepath.Join(home, ".config", "opencode", "skill", "basecamp")
require.NoError(t, os.MkdirAll(opencode, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(opencode, "SKILL.md"), []byte("old"), 0o644))

refreshed := refreshAllInstalledSkills()
assert.True(t, refreshed)

// All three should be updated
for _, path := range []string{
filepath.Join(baseline, "SKILL.md"),
filepath.Join(claudeSkill, "SKILL.md"),
filepath.Join(opencode, "SKILL.md"),
} {
got, readErr := os.ReadFile(path)
require.NoError(t, readErr, "reading %s", path)
assert.Equal(t, string(embedded), string(got), "content mismatch at %s", path)
}

// Version stamp should be updated
assert.Equal(t, "5.0.0", installedSkillVersion())
}

func TestRefreshAllInstalledSkills_SkipsAbsentLocations(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", home) // no claude binary

origVersion := version.Version
version.Version = "5.0.0"
defer func() { version.Version = origVersion }()

// Only install at baseline
baseline := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(baseline, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(baseline, "SKILL.md"), []byte("old"), 0o644))

refreshed := refreshAllInstalledSkills()
assert.True(t, refreshed)

// Claude location should NOT have been created
claudeSkill := filepath.Join(home, ".claude", "skills", "basecamp", "SKILL.md")
_, err := os.Stat(claudeSkill)
assert.True(t, os.IsNotExist(err), "should not create skill at absent location")
}

func TestRefreshAllInstalledSkills_SkipsProjectRelativePaths(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("PATH", home) // no claude binary

origVersion := version.Version
version.Version = "5.0.0"
defer func() { version.Version = origVersion }()

// Use a temp dir as working directory to avoid polluting the repo
projectDir := t.TempDir()
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(projectDir))
defer os.Chdir(origDir) //nolint:errcheck // cleanup

// Create project-relative skill file in the temp working directory
require.NoError(t, os.MkdirAll(filepath.Join(".claude", "skills", "basecamp"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(".claude", "skills", "basecamp", "SKILL.md"), []byte("project"), 0o644))

// Install baseline so refresh returns true
baseline := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(baseline, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(baseline, "SKILL.md"), []byte("old"), 0o644))

refreshAllInstalledSkills()

// Project-relative file should be untouched
got, err := os.ReadFile(filepath.Join(".claude", "skills", "basecamp", "SKILL.md"))
require.NoError(t, err)
assert.Equal(t, "project", string(got), "project-relative skill should not be refreshed")
}

func TestRepairClaudeSkillLink_BrokenSymlink(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

// Ensure ~/.claude/skills exists so the symlink can be placed there
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills"), 0o755))

// Create baseline skill
baseline := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(baseline, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(baseline, "SKILL.md"), []byte("skill"), 0o644))

// Create a broken symlink
symlinkPath := filepath.Join(home, ".claude", "skills", "basecamp")
require.NoError(t, os.Symlink("/nonexistent/target", symlinkPath))

// Verify it's broken
_, err := os.Stat(symlinkPath)
require.True(t, os.IsNotExist(err), "symlink should be broken")

repairClaudeSkillLink()

// Symlink should now be healthy
_, err = os.Stat(filepath.Join(symlinkPath, "SKILL.md"))
assert.NoError(t, err, "skill should be reachable through repaired symlink")
}

func TestRepairClaudeSkillLink_HealthySymlink(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

// Create baseline skill and a healthy symlink
baseline := filepath.Join(home, ".agents", "skills", "basecamp")
require.NoError(t, os.MkdirAll(baseline, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(baseline, "SKILL.md"), []byte("skill"), 0o644))

symlinkDir := filepath.Join(home, ".claude", "skills")
require.NoError(t, os.MkdirAll(symlinkDir, 0o755))
require.NoError(t, os.Symlink(baseline, filepath.Join(symlinkDir, "basecamp")))

// Read the symlink target before repair
targetBefore, _ := os.Readlink(filepath.Join(symlinkDir, "basecamp"))

repairClaudeSkillLink()

// Target should be unchanged (no unnecessary repair)
targetAfter, _ := os.Readlink(filepath.Join(symlinkDir, "basecamp"))
assert.Equal(t, targetBefore, targetAfter, "healthy symlink should not be modified")
}
Loading