From f2c5b258a29154a1822daf680e47d1bce9f960a8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 13:40:41 -0700 Subject: [PATCH 1/4] Refresh skills at all installed locations on CLI upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefreshSkillsIfVersionChanged now updates the skill file at every known install location (Claude Code, OpenCode, Codex) — not just the baseline ~/.agents/ directory. Broken Claude symlinks are repaired automatically. Project-relative paths are skipped since there is no reliable project root in the post-run hook. --- internal/commands/skill.go | 75 +++++++++++++++-- internal/commands/skill_test.go | 145 +++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 7 deletions(-) diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 08508eb2..cb82c2fb 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,69 @@ func RefreshSkillsIfVersionChanged() bool { return refreshed } +// refreshAllInstalledSkills overwrites the skill file at every known location +// where it was previously installed. Project-relative paths are skipped because +// there is no reliable project root in the post-run hook. Returns true if at +// least one file was updated. +func refreshAllInstalledSkills() bool { + embedded, err := skills.FS.ReadFile("basecamp/SKILL.md") + if err != nil { + return false + } + + refreshed := false + 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 { + continue // not installed at this location + } + + if writeErr := os.WriteFile(expanded, embedded, 0o644); writeErr == nil { //nolint:gosec // G306: Skill files are not secrets + refreshed = true + } + } + + // Stamp installed version in the baseline directory. + 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 refreshed +} + +// 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..c89e9409 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,146 @@ 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 }() + + // Create project-relative skill file in cwd + 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)) + defer os.RemoveAll(".claude") //nolint:errcheck // cleanup + + // 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) + + // Create ~/.claude so DetectClaude returns true + 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") +} From d2287872c22e84f9c54931246e675789f4964ce4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 15:20:29 -0700 Subject: [PATCH 2/4] Address review feedback: sentinel, version stamp, test isolation --- internal/commands/skill.go | 23 ++++++++++++----------- internal/commands/skill_test.go | 9 +++++++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/commands/skill.go b/internal/commands/skill.go index cb82c2fb..0b385d42 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -351,17 +351,14 @@ func RefreshSkillsIfVersionChanged() bool { return refreshed } -// refreshAllInstalledSkills overwrites the skill file at every known location -// where it was previously installed. Project-relative paths are skipped because -// there is no reliable project root in the post-run hook. Returns true if at -// least one file was updated. func refreshAllInstalledSkills() bool { embedded, err := skills.FS.ReadFile("basecamp/SKILL.md") if err != nil { return false } - refreshed := 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) { @@ -374,17 +371,21 @@ func refreshAllInstalledSkills() bool { } if writeErr := os.WriteFile(expanded, embedded, 0o644); writeErr == nil { //nolint:gosec // G306: Skill files are not secrets - refreshed = true + updated++ + } else { + failed++ } } - // Stamp installed version in the baseline directory. - 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 + // 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 refreshed + return updated > 0 && failed == 0 } // repairClaudeSkillLink repairs a broken symlink at ~/.claude/skills/basecamp. diff --git a/internal/commands/skill_test.go b/internal/commands/skill_test.go index c89e9409..e4657b1f 100644 --- a/internal/commands/skill_test.go +++ b/internal/commands/skill_test.go @@ -548,10 +548,15 @@ func TestRefreshAllInstalledSkills_SkipsProjectRelativePaths(t *testing.T) { version.Version = "5.0.0" defer func() { version.Version = origVersion }() - // Create project-relative skill file in cwd + // Use a temp dir as working directory to avoid polluting the repo + projectDir := t.TempDir() + origDir, _ := os.Getwd() + 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)) - defer os.RemoveAll(".claude") //nolint:errcheck // cleanup // Install baseline so refresh returns true baseline := filepath.Join(home, ".agents", "skills", "basecamp") From 1330481cd75b6cd0b31746a4fa6f2d966506ef87 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:03:33 -0700 Subject: [PATCH 3/4] Check Getwd error in test --- internal/commands/skill_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/commands/skill_test.go b/internal/commands/skill_test.go index e4657b1f..91764f6b 100644 --- a/internal/commands/skill_test.go +++ b/internal/commands/skill_test.go @@ -550,7 +550,8 @@ func TestRefreshAllInstalledSkills_SkipsProjectRelativePaths(t *testing.T) { // Use a temp dir as working directory to avoid polluting the repo projectDir := t.TempDir() - origDir, _ := os.Getwd() + origDir, err := os.Getwd() + require.NoError(t, err) require.NoError(t, os.Chdir(projectDir)) defer os.Chdir(origDir) //nolint:errcheck // cleanup From 5e7dbd937eee335ce8d951892c07ae60c1985e97 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:23:56 -0700 Subject: [PATCH 4/4] Distinguish stat errors, fix test comment --- internal/commands/skill.go | 5 ++++- internal/commands/skill_test.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 0b385d42..8ce645b8 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -367,7 +367,10 @@ func refreshAllInstalledSkills() bool { expanded := expandSkillPath(loc.Path) if _, statErr := os.Stat(expanded); statErr != nil { - continue // not installed at this location + 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 diff --git a/internal/commands/skill_test.go b/internal/commands/skill_test.go index 91764f6b..6422ba4b 100644 --- a/internal/commands/skill_test.go +++ b/internal/commands/skill_test.go @@ -576,7 +576,7 @@ func TestRepairClaudeSkillLink_BrokenSymlink(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - // Create ~/.claude so DetectClaude returns true + // 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