From a8d0c049ee98eeea3d757cb87b96388e17e7604b Mon Sep 17 00:00:00 2001 From: Guillaume Date: Sat, 10 Oct 2026 17:55:34 +0200 Subject: [PATCH] feat: restore dev mode after a marketplace auto-update Claude Code's plugin auto-update repoints a dev-mode plugin at the cache as soon as the marketplace publishes a new version. dev now records the dev path in /cplugins/dev.json and repair, run at session start, points the plugin back at it; undev drops the record. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015yD1s8VzXhcfHp2wbuFqNZ --- AGENTS.md | 3 +- cmd/dev.go | 5 + cmd/devstate.go | 111 +++++++++++++ cmd/repair.go | 60 ++++++- cmd/undev.go | 4 + e2e/dev_survives_update_test.go | 248 +++++++++++++++++++++++++++++ internal/claude/installed_write.go | 16 +- 7 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 cmd/devstate.go create mode 100644 e2e/dev_survives_update_test.go diff --git a/AGENTS.md b/AGENTS.md index 9da8148..31f1cdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ e2e/ - `dev ` — patch `installPath` to a local source dir - `undev ` — re-cache the plugin from its marketplace source and point `installPath` back at the cache - `status [plugin@marketplace]` (aliases `ls`, `list`) — list plugins and their health; user-scope always visible, project-scope filtered by CWD -- `repair [plugin@marketplace]` — re-cache plugins whose `installPath` no longer exists +- `repair [plugin@marketplace]` — re-cache plugins whose `installPath` no longer exists; re-point plugins recorded by `dev` at their dev path when Claude Code's auto-update moved them back to the cache - `prune` — remove project-scope installs whose `projectPath` no longer exists, dropping the plugin's key entirely once none are left - `clean` — remove orphaned cache entries @@ -44,6 +44,7 @@ e2e/ ## Key conventions - `dev` resolves symlinks at write time (`filepath.EvalSymlinks`) to store canonical paths; `undev` re-caches from the marketplace source +- Dev mode is recorded in `/cplugins/dev.json` (`cmd/devstate.go`): `dev` writes the entry, `repair` records dev-mode plugins it finds unrecorded, `undev` and `repair` drop it when the plugin leaves dev mode or its dev path disappears - `installed_plugins.json` installPath patches are via `claude.PatchInstallPaths`/`claude.PatchInstalls`; install removal is via `claude.RemoveInstalls` - Never modify `known_marketplaces.json` — delegate to `claude plugin marketplace add/remove` - `claude-plugins-official` plugins are always skipped (managed by Claude Code) diff --git a/cmd/dev.go b/cmd/dev.go index f97f73a..a1bc8c5 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -45,9 +45,14 @@ func runDev(cmd *cobra.Command, args []string) error { return fmt.Errorf("no plugin.json at %s — is this a valid plugin directory?", pluginJSON) } + state := loadDevState(cmd.ErrOrStderr()) + if err := state.record(key, devPath); err != nil { + return fmt.Errorf("recording dev mode: %w", err) + } if err := claude.PatchInstallPaths(installedPluginsPath(), key, func(_, _ string) string { return devPath }); err != nil { + _ = state.forget(key) return fmt.Errorf("patching installPath: %w", err) } diff --git a/cmd/devstate.go b/cmd/devstate.go new file mode 100644 index 0000000..289b4be --- /dev/null +++ b/cmd/devstate.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/Evaneos/cplugins/internal/claude" +) + +// Claude Code's plugin auto-update repoints a dev-mode plugin at the cache as +// soon as the marketplace publishes a new version, so dev mode is recorded +// here, outside installed_plugins.json, for repair to restore it. +// +// Layout: {"plugins": {"": {"path": ""}}}. +// Unknown fields are kept on rewrite. + +func devStatePath() string { + return filepath.Join(claudeDir(), "cplugins", "dev.json") +} + +type devState struct { + doc map[string]any + plugins map[string]any + // readOnly is set when the file exists but couldn't be loaded and stays + // in place: saving would overwrite it. + readOnly bool +} + +// loadDevState never fails, so repair keeps working at session start: a +// missing file yields an empty state, an unparsable one is set aside, and an +// unreadable one is left untouched with saving disabled. +func loadDevState(warn io.Writer) *devState { + s := &devState{doc: map[string]any{}, plugins: map[string]any{}} + data, err := os.ReadFile(devStatePath()) + if errors.Is(err, os.ErrNotExist) { + return s + } + if err != nil { + fmt.Fprintf(warn, "⚠ dev state unreadable, left untouched: %v\n", err) + s.readOnly = true + return s + } + if err := json.Unmarshal(data, &s.doc); err != nil { + s.doc = map[string]any{} + aside := fmt.Sprintf("%s.unreadable-%s", devStatePath(), time.Now().Format("20060102T150405")) + if rerr := os.Rename(devStatePath(), aside); rerr != nil { + fmt.Fprintf(warn, "⚠ dev state unparsable (%v), left untouched: %v\n", err, rerr) + s.readOnly = true + return s + } + fmt.Fprintf(warn, "⚠ dev state unparsable (%v), set aside as %s\n", err, aside) + return s + } + if p, ok := s.doc["plugins"].(map[string]any); ok { + s.plugins = p + } + return s +} + +// path returns the recorded dev path of key, or "" when none is recorded. +func (s *devState) path(key string) string { + entry, _ := s.plugins[key].(map[string]any) + p, _ := entry["path"].(string) + return p +} + +func (s *devState) keys() []string { + keys := make([]string, 0, len(s.plugins)) + for k := range s.plugins { + keys = append(keys, k) + } + return keys +} + +func (s *devState) record(key, path string) error { + entry, ok := s.plugins[key].(map[string]any) + if !ok { + entry = map[string]any{} + } + entry["path"] = path + s.plugins[key] = entry + return s.save() +} + +func (s *devState) forget(key string) error { + if _, ok := s.plugins[key]; !ok { + return nil + } + delete(s.plugins, key) + return s.save() +} + +func (s *devState) save() error { + if s.readOnly { + return fmt.Errorf("%s could not be loaded, not overwriting it", devStatePath()) + } + s.doc["plugins"] = s.plugins + data, err := json.MarshalIndent(s.doc, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(devStatePath()), 0o755); err != nil { + return err + } + return claude.WriteFileAtomic(devStatePath(), append(data, '\n')) +} diff --git a/cmd/repair.go b/cmd/repair.go index 11a4fdb..c399695 100644 --- a/cmd/repair.go +++ b/cmd/repair.go @@ -3,7 +3,9 @@ package cmd import ( "fmt" "os" + "path/filepath" + "github.com/Evaneos/cplugins/internal/cache" "github.com/Evaneos/cplugins/internal/claude" "github.com/spf13/cobra" ) @@ -12,7 +14,8 @@ var repairCmd = &cobra.Command{ Use: "repair [plugin@marketplace]", Short: "Recache broken plugins from their marketplace source", Long: `Repair fixes plugins whose installPath no longer exists by re-copying them -from the marketplace source directory into the cache. +from the marketplace source directory into the cache, and points plugins +recorded by dev back at their dev path when an update moved them to the cache. Without arguments, repairs all broken plugins. With a plugin key, repairs only that plugin.`, Args: cobra.MaximumNArgs(1), @@ -47,8 +50,49 @@ func runRepair(cmd *cobra.Command, args []string) error { } } + devState := loadDevState(cmd.ErrOrStderr()) + if len(args) == 0 { + for _, key := range devState.keys() { + if _, ok := plugins[key]; !ok { + if err := devState.forget(key); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "⚠ %s: %v\n", key, err) + } + } + } + } + repaired := 0 for _, p := range targets { + current := devModePath(cacheBaseDir(), p.Installs) + devPath := devState.path(p.Key) + + // The active dev path wins over the record. + if current != "" && current != devPath && isPluginDir(current) { + if err := devState.record(p.Key, current); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "⚠ %s: recording dev mode: %v\n", p.Key, err) + } + devPath = current + } + + if devPath != "" && !isPluginDir(devPath) { + if err := devState.forget(p.Key); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "⚠ %s: %v\n", p.Key, err) + } + devPath = "" + } + + if devPath != "" && hasCachedInstall(p) { + if err := claude.PatchInstallPaths(installedPluginsPath(), p.Key, func(_, _ string) string { + return devPath + }); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "⚠ %s: %v\n", p.Key, err) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "↺ %s → %s (dev mode restored after an update)\n", p.Key, devPath) + repaired++ + continue + } + if !isBroken(p) { continue } @@ -81,6 +125,20 @@ func isBroken(p *claude.Plugin) bool { return false } +func hasCachedInstall(p *claude.Plugin) bool { + for _, inst := range p.Installs { + if cache.IsUnderCache(cacheBaseDir(), inst.InstallPath) { + return true + } + } + return false +} + +func isPluginDir(path string) bool { + _, err := os.Stat(filepath.Join(path, ".claude-plugin", "plugin.json")) + return err == nil +} + func completeBrokenPlugins(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/undev.go b/cmd/undev.go index 4ede0da..d643044 100644 --- a/cmd/undev.go +++ b/cmd/undev.go @@ -31,6 +31,10 @@ func runUndev(cmd *cobra.Command, args []string) error { return fmt.Errorf("plugin %q not found in installed_plugins.json", key) } + // Forgotten first: a record left behind would make repair undo the undev. + if err := loadDevState(cmd.ErrOrStderr()).forget(key); err != nil { + return fmt.Errorf("forgetting dev mode: %w", err) + } if devModePath(cacheBaseDir(), plugin.Installs) == "" { return fmt.Errorf("plugin %q is not in dev mode", key) } diff --git a/e2e/dev_survives_update_test.go b/e2e/dev_survives_update_test.go new file mode 100644 index 0000000..1343a1c --- /dev/null +++ b/e2e/dev_survives_update_test.go @@ -0,0 +1,248 @@ +package e2e_test + +// dev_survives_update_test.go covers repair restoring dev mode after Claude +// Code's plugin auto-update repointed a dev-mode plugin at the cache. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// simulateAutoUpdate points every install of key at cachePath, as Claude +// Code's auto-update does when the marketplace publishes a new version. +func simulateAutoUpdate(t *testing.T, home, key, cachePath string) { + t.Helper() + var data map[string]any + readJSON(t, installedPluginsPath(home), &data) + plugins, _ := data["plugins"].(map[string]any) + entries, _ := plugins[key].([]any) + for _, e := range entries { + e.(map[string]any)["installPath"] = cachePath + } + writeJSON(t, installedPluginsPath(home), data) +} + +func devStateFile(home string) string { + return filepath.Join(home, ".claude", "cplugins", "dev.json") +} + +// devState returns the recorded dev path of each plugin key. +func devState(t *testing.T, home string) map[string]string { + t.Helper() + state := map[string]string{} + if _, err := os.Stat(devStateFile(home)); err != nil { + return state + } + var doc struct { + Plugins map[string]struct { + Path string `json:"path"` + } `json:"plugins"` + } + readJSON(t, devStateFile(home), &doc) + for k, v := range doc.Plugins { + state[k] = v.Path + } + return state +} + +func setupDevPlugin(t *testing.T) (home, key, cachePath, src string) { + t.Helper() + home = setupHome(t) + key = "hello@test-mp" + newMarketplace(t, home, "test-mp", []pluginSpec{{"hello", "0.1.0"}}) + installPlugin(t, home, key) + cachePath = installPath(t, home, key) + src, _ = filepath.EvalSymlinks(newPluginSource(t, "hello", "0.1.0")) + mustCplugins(t, home, "dev", key, src) + return home, key, cachePath, src +} + +func TestRepair_restoresDevAfterUpdate(t *testing.T) { + home, key, cachePath, src := setupDevPlugin(t) + simulateAutoUpdate(t, home, key, cachePath) + + out := mustCplugins(t, home, "repair") + + assertContains(t, out, key) + assertContains(t, out, "dev mode restored") + if ip := installPath(t, home, key); ip != src { + t.Errorf("installPath = %q, want %q", ip, src) + } +} + +func TestRepair_devStillActive_leftAlone(t *testing.T) { + home, key, _, src := setupDevPlugin(t) + + out := mustCplugins(t, home, "repair") + + assertContains(t, out, "Nothing to repair.") + if ip := installPath(t, home, key); ip != src { + t.Errorf("installPath = %q, want %q", ip, src) + } +} + +func TestRepair_devPathGone_forgetsDev(t *testing.T) { + home, key, cachePath, src := setupDevPlugin(t) + simulateAutoUpdate(t, home, key, cachePath) + if err := os.RemoveAll(src); err != nil { + t.Fatal(err) + } + + out := mustCplugins(t, home, "repair") + + if strings.Contains(out, "dev mode restored") { + t.Errorf("restored a dev path that no longer exists: %s", out) + } + if ip := installPath(t, home, key); ip != cachePath { + t.Errorf("installPath = %q, want %q", ip, cachePath) + } + if _, ok := devState(t, home)[key]; ok { + t.Errorf("dev record kept for a dev path that no longer exists") + } +} + +func TestUndev_forgetsDev(t *testing.T) { + home, key, cachePath, _ := setupDevPlugin(t) + + mustCplugins(t, home, "undev", key) + if _, ok := devState(t, home)[key]; ok { + t.Fatalf("dev record kept after undev") + } + + mustCplugins(t, home, "repair") + if ip := installPath(t, home, key); ip != cachePath { + t.Errorf("repair re-applied dev after undev: installPath = %q", ip) + } +} + +func TestUndev_afterUpdate_forgetsDev(t *testing.T) { + home, key, cachePath, _ := setupDevPlugin(t) + simulateAutoUpdate(t, home, key, cachePath) + + if _, _, err := runCplugins(t, home, "undev", key); err == nil { + t.Fatalf("expected undev to report the plugin is not in dev mode") + } + if _, ok := devState(t, home)[key]; ok { + t.Errorf("dev record kept after undev") + } +} + +func TestRepair_devWithoutRecord_recordedThenRestored(t *testing.T) { + home, key, cachePath, src := setupDevPlugin(t) + if err := os.RemoveAll(filepath.Dir(devStateFile(home))); err != nil { + t.Fatal(err) + } + + mustCplugins(t, home, "repair") + if got := devState(t, home)[key]; got != src { + t.Fatalf("dev record = %q, want %q", got, src) + } + + simulateAutoUpdate(t, home, key, cachePath) + mustCplugins(t, home, "repair") + if ip := installPath(t, home, key); ip != src { + t.Errorf("installPath = %q, want %q", ip, src) + } +} + +func TestRepair_unreadableDevState_setAsideAndKeepsWorking(t *testing.T) { + home, key, cachePath, _ := setupDevPlugin(t) + simulateAutoUpdate(t, home, key, cachePath) + if err := os.WriteFile(devStateFile(home), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + + stdout, stderr, err := runCplugins(t, home, "repair") + mustSucceed(t, "cplugins repair", stdout, stderr, err) + + assertContains(t, stderr, "set aside") + matches, _ := filepath.Glob(devStateFile(home) + ".unreadable-*") + if len(matches) != 1 { + t.Errorf("expected the unreadable file set aside, found %v", matches) + } +} + +func TestDev_keepsUnknownFieldsInDevState(t *testing.T) { + home, key, _, _ := setupDevPlugin(t) + writeJSON(t, devStateFile(home), map[string]any{ + "schema": 2, + "plugins": map[string]any{key: map[string]any{"path": "/x", "branch": "feat"}}, + }) + + src, _ := filepath.EvalSymlinks(newPluginSource(t, "hello", "0.1.0")) + mustCplugins(t, home, "dev", key, src) + + var doc map[string]any + readJSON(t, devStateFile(home), &doc) + if doc["schema"] != float64(2) { + t.Errorf("top-level field dropped: %v", doc) + } + entry := doc["plugins"].(map[string]any)[key].(map[string]any) + if entry["branch"] != "feat" || entry["path"] != src { + t.Errorf("entry = %v, want branch kept and path %q", entry, src) + } +} + +func TestRepair_uninstalledPlugin_forgetsDev(t *testing.T) { + home, key, _, _ := setupDevPlugin(t) + removeInstalledPlugin(t, home, key) + + mustCplugins(t, home, "repair") + + if _, ok := devState(t, home)[key]; ok { + t.Errorf("dev record kept for an uninstalled plugin") + } +} + +func TestRepair_recordFollowsActiveDevPath(t *testing.T) { + home, key, cachePath, _ := setupDevPlugin(t) + other, _ := filepath.EvalSymlinks(newPluginSource(t, "hello", "0.1.0")) + simulateAutoUpdate(t, home, key, other) + + mustCplugins(t, home, "repair") + simulateAutoUpdate(t, home, key, cachePath) + mustCplugins(t, home, "repair") + + if ip := installPath(t, home, key); ip != other { + t.Errorf("installPath = %q, want the last active dev path %q", ip, other) + } +} + +func TestRepair_partialUpdate_restoresAllInstalls(t *testing.T) { + home, key, cachePath, src := setupDevPlugin(t) + addInstallEntry(t, home, key, map[string]any{ + "scope": "project", "projectPath": t.TempDir(), + "installPath": cachePath, "version": "0.1.0", + }) + + mustCplugins(t, home, "repair") + + var data map[string]any + readJSON(t, installedPluginsPath(home), &data) + for _, e := range data["plugins"].(map[string]any)[key].([]any) { + if ip := e.(map[string]any)["installPath"]; ip != src { + t.Errorf("install left at %v, want %q", ip, src) + } + } +} + +func TestRepair_unreadableDevStateFile_leftUntouched(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root reads files regardless of permissions") + } + home, _, _, _ := setupDevPlugin(t) + if err := os.Chmod(devStateFile(home), 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(devStateFile(home), 0o644) }) + + stdout, stderr, err := runCplugins(t, home, "repair") + mustSucceed(t, "cplugins repair", stdout, stderr, err) + + assertContains(t, stderr, "left untouched") + if _, err := os.Stat(devStateFile(home)); err != nil { + t.Errorf("dev state moved or removed: %v", err) + } +} diff --git a/internal/claude/installed_write.go b/internal/claude/installed_write.go index eeb8ccb..49cc1de 100644 --- a/internal/claude/installed_write.go +++ b/internal/claude/installed_write.go @@ -171,10 +171,7 @@ func readRegistry(path string) (*orderedObject, error) { return root, nil } -// writeRegistry serializes root back to path, atomically: it writes to a -// temporary file in the same directory and renames it into place, so a -// concurrent reader never observes a partial write. The temporary file is -// removed if any step fails. +// writeRegistry serializes root back to path atomically. func writeRegistry(path string, root *orderedObject) error { raw, err := root.MarshalJSON() if err != nil { @@ -184,13 +181,20 @@ func writeRegistry(path string, root *orderedObject) error { if err := json.Indent(&pretty, raw, "", " "); err != nil { return fmt.Errorf("formatting installed plugins: %w", err) } + return WriteFileAtomic(path, pretty.Bytes()) +} +// WriteFileAtomic writes data to path through a temporary file renamed into +// place, keeping path's permissions when it exists, so a concurrent reader +// never observes a partial write. The temporary file is removed if any step +// fails. +func WriteFileAtomic(path string, data []byte) error { mode := os.FileMode(0o644) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() } - tmp, err := os.CreateTemp(filepath.Dir(path), ".installed_plugins-*.tmp") + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") if err != nil { return fmt.Errorf("creating temp file: %w", err) } @@ -206,7 +210,7 @@ func writeRegistry(path string, root *orderedObject) error { _ = tmp.Close() return fmt.Errorf("setting temp file permissions: %w", err) } - if _, err := tmp.Write(pretty.Bytes()); err != nil { + if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("writing temp file: %w", err) }