diff --git a/README.md b/README.md index a0743d5..88bddc1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ mv gander ~/go/bin/gander # or any directory in your PATH gander --upgrade ``` -Downloads the latest release binary that matches your OS/arch, verifies its SHA256 checksum, and atomically replaces the running binary. Sets `GITHUB_TOKEN` in the environment to raise the API rate limit on shared networks. +Downloads the latest release binary that matches your OS/arch, verifies its SHA256 checksum, and atomically replaces the running binary. If `~/.gander/skill` is already installed, the same command also pulls the latest [`gandermd/gander-skill`](https://github.com/gandermd/gander-skill) and re-links agent dests. Sets `GITHUB_TOKEN` in the environment to raise the API rate limit on shared networks. If you built from source the old-fashioned way, re-run `install.sh` (or `git pull && ./install.sh --source`). @@ -257,6 +257,7 @@ The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named p Download and install the latest release, then exit. The runner is shut down over UDS first, the binary is replaced, then the supervisor (or a fresh spawn) brings the upgraded daemon back up with the same watches. + If the agent skill is already installed, also refreshes ~/.gander/skill. ``` Subcommands: diff --git a/man/man1/gander.1 b/man/man1/gander.1 index 948a03e..de6a536 100644 --- a/man/man1/gander.1 +++ b/man/man1/gander.1 @@ -117,6 +117,8 @@ or debugging the watcher itself. .TP .BR \-upgrade ", " \-\-upgrade Download and install the latest release, then exit. +If the agent skill is already installed, also refresh +.IR ~/.gander/skill . .TP .BR \-\-help ", " \-h Print help and exit. @@ -322,6 +324,15 @@ instead. .BR \-\-upgrade Download the latest release binary for the current platform, verify its checksum, atomically replace the running binary, and exit. +If +.I ~/.gander/skill +(or a dest symlink pointing at it) is already present, also pull the +latest +.B gandermd/gander-skill +and re-link the agent skill directories, matching +.BR "gander skill" . +A skill refresh failure does not undo a successful binary replace; the +command still exits non-zero. .SH FILES .TP .I ~/.gander diff --git a/skill.go b/skill.go index 001ab64..6a80459 100644 --- a/skill.go +++ b/skill.go @@ -33,6 +33,33 @@ func runSkill(args []string) error { return fmt.Errorf("usage: gander skill [install]") } +func skillAlreadyInstalled() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + skillDir := filepath.Join(home, ".gander", "skill") + if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err == nil { + return true + } + for _, d := range skillDests { + if isOurSkillLink(filepath.Join(home, d.rel), skillDir) { + return true + } + } + return false +} + +func refreshSkillIfInstalled() error { + if !skillAlreadyInstalled() { + return nil + } + if err := runSkillInstall(); err != nil { + return fmt.Errorf("skill: %w", err) + } + return nil +} + func runSkillInstall() error { home, err := os.UserHomeDir() if err != nil { diff --git a/skill_test.go b/skill_test.go index 483846d..4b48bdd 100644 --- a/skill_test.go +++ b/skill_test.go @@ -162,6 +162,44 @@ func TestSkillInstallPrefersAgentsLayout(t *testing.T) { } } +func TestSkillAlreadyInstalled(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if skillAlreadyInstalled() { + t.Fatal("want false when nothing installed") + } + + skillDir := filepath.Join(home, ".gander", "skill") + if err := os.MkdirAll(skillDir, 0700); err != nil { + t.Fatal(err) + } + if skillAlreadyInstalled() { + t.Fatal("empty skill dir without SKILL.md is not installed") + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Gander\n"), 0644); err != nil { + t.Fatal(err) + } + if !skillAlreadyInstalled() { + t.Fatal("want true when ~/.gander/skill/SKILL.md exists") + } +} + +func TestSkillAlreadyInstalledDestSymlink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + skillDir := filepath.Join(home, ".gander", "skill") + dest := filepath.Join(home, ".claude", "skills", "gander") + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(skillDir, dest); err != nil { + t.Fatal(err) + } + if !skillAlreadyInstalled() { + t.Fatal("want true when a dest is our skill symlink") + } +} + func TestSkillRejectsBadUsage(t *testing.T) { err := runSkill([]string{"foo"}) if err == nil { diff --git a/upgrade.go b/upgrade.go index b205608..96ce6e9 100644 --- a/upgrade.go +++ b/upgrade.go @@ -61,42 +61,42 @@ func runUpgrade() error { if Version != "dev" && rel.TagName == Version { fmt.Println("Already on the latest version.") - return nil - } + } else { + assetName := assetNameForRuntime() + asset, ok := findAsset(rel.Assets, assetName) + if !ok { + return fmt.Errorf("no release asset named %s in %s; available: %s", + assetName, rel.HTMLURL, listAssetNames(rel.Assets)) + } - assetName := assetNameForRuntime() - asset, ok := findAsset(rel.Assets, assetName) - if !ok { - return fmt.Errorf("no release asset named %s in %s; available: %s", - assetName, rel.HTMLURL, listAssetNames(rel.Assets)) - } + fmt.Printf("Found %s, downloading %s...\n", rel.TagName, assetName) - fmt.Printf("Found %s, downloading %s...\n", rel.TagName, assetName) + binPath, err := downloadToTemp(asset.BrowserDownloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer os.Remove(binPath) - binPath, err := downloadToTemp(asset.BrowserDownloadURL) - if err != nil { - return fmt.Errorf("download: %w", err) - } - defer os.Remove(binPath) + sum, err := downloadSha256(asset.BrowserDownloadURL + ".sha256") + if err != nil { + return fmt.Errorf("download checksum: %w", err) + } - sum, err := downloadSha256(asset.BrowserDownloadURL + ".sha256") - if err != nil { - return fmt.Errorf("download checksum: %w", err) - } + if err := verifySha256(binPath, sum); err != nil { + return fmt.Errorf("checksum mismatch: %w", err) + } - if err := verifySha256(binPath, sum); err != nil { - return fmt.Errorf("checksum mismatch: %w", err) - } + if err := installBinary(binPath, exePath); err != nil { + return fmt.Errorf("install: %w", err) + } - if err := installBinary(binPath, exePath); err != nil { - return fmt.Errorf("install: %w", err) - } + fmt.Printf("Upgraded %s -> %s\n", Version, rel.TagName) + fmt.Printf("Release notes: %s\n", rel.HTMLURL) - fmt.Printf("Upgraded %s -> %s\n", Version, rel.TagName) - fmt.Printf("Release notes: %s\n", rel.HTMLURL) + restartDaemonAfterUpgrade(exePath) + } - restartDaemonAfterUpgrade(exePath) - return nil + return refreshSkillIfInstalled() } // stopDaemonForUpgrade asks the live runner to shut down over IPC so the diff --git a/upgrade_test.go b/upgrade_test.go index 164cf5f..56a127f 100644 --- a/upgrade_test.go +++ b/upgrade_test.go @@ -1,8 +1,11 @@ package main import ( + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -34,3 +37,140 @@ func TestIsRunnerSupervisedNoUnit(t *testing.T) { t.Skip("runner is currently supervised in this environment") } } + +func TestUpgradeRefreshesInstalledSkill(t *testing.T) { + home := isolatedUpgradeHome(t) + skillDir := filepath.Join(home, ".gander", "skill") + if err := os.MkdirAll(skillDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# old\n"), 0644); err != nil { + t.Fatal(err) + } + serveSkillArchive(t, map[string]string{ + "gander-skill-main/SKILL.md": "# new\n", + }) + stubUpgradeAlreadyCurrent(t, "v9.9.9") + + if err := runUpgrade(); err != nil { + t.Fatalf("runUpgrade: %v", err) + } + body, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(body) != "# new\n" { + t.Errorf("SKILL.md = %q, want refreshed copy", body) + } + for _, dest := range skillDestPaths(home) { + assertSkillLinked(t, dest, skillDir) + } +} + +func TestUpgradeRefreshesSkillFromDestSymlink(t *testing.T) { + home := isolatedUpgradeHome(t) + skillDir := filepath.Join(home, ".gander", "skill") + dest := filepath.Join(home, ".claude", "skills", "gander") + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(skillDir, dest); err != nil { + t.Fatal(err) + } + serveSkillArchive(t, map[string]string{ + "gander-skill-main/SKILL.md": "# from dest\n", + }) + stubUpgradeAlreadyCurrent(t, "v9.9.9") + + if err := runUpgrade(); err != nil { + t.Fatalf("runUpgrade: %v", err) + } + body, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(body) != "# from dest\n" { + t.Errorf("SKILL.md = %q, want dest-triggered refresh", body) + } +} + +func TestUpgradeSkipsSkillWhenNotInstalled(t *testing.T) { + home := isolatedUpgradeHome(t) + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + http.Error(w, "should not download skill", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + prev := skillDownloadURL + skillDownloadURL = srv.URL + t.Cleanup(func() { skillDownloadURL = prev }) + stubUpgradeAlreadyCurrent(t, "v9.9.9") + + if err := runUpgrade(); err != nil { + t.Fatalf("runUpgrade: %v", err) + } + if hit { + t.Error("skill download ran even though skill was not installed") + } + for _, dest := range skillDestPaths(home) { + if _, err := os.Lstat(dest); !os.IsNotExist(err) { + t.Errorf("created dest %s: %v", dest, err) + } + } +} + +func TestUpgradeSkillFailureDoesNotUndoCLI(t *testing.T) { + home := isolatedUpgradeHome(t) + skillDir := filepath.Join(home, ".gander", "skill") + if err := os.MkdirAll(skillDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# old\n"), 0644); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + prev := skillDownloadURL + skillDownloadURL = srv.URL + t.Cleanup(func() { skillDownloadURL = prev }) + stubUpgradeAlreadyCurrent(t, "v9.9.9") + + err := runUpgrade() + if err == nil { + t.Fatal("expected skill error") + } + if !strings.Contains(err.Error(), "skill:") { + t.Errorf("err = %v, want wrapped skill: prefix", err) + } + body, readErr := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if readErr != nil { + t.Fatal(readErr) + } + if string(body) != "# old\n" { + t.Errorf("SKILL.md replaced after failed download: %q", body) + } +} + +func isolatedUpgradeHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GANDER_CONFIG", "") + return home +} + +func stubUpgradeAlreadyCurrent(t *testing.T, version string) { + t.Helper() + prevV := Version + Version = version + t.Cleanup(func() { Version = prevV }) + + prevFetch := fetchLatestRelease + fetchLatestRelease = func() (*releaseInfo, error) { + return &releaseInfo{TagName: version}, nil + } + t.Cleanup(func() { fetchLatestRelease = prevFetch }) +}