diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 08508eb2..8ce645b8 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -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 @@ -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 diff --git a/internal/commands/skill_test.go b/internal/commands/skill_test.go index da94d00b..6422ba4b 100644 --- a/internal/commands/skill_test.go +++ b/internal/commands/skill_test.go @@ -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) @@ -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") +}