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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ e2e/
- `dev <plugin@marketplace> <path>` — patch `installPath` to a local source dir
- `undev <plugin@marketplace>` — 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

Expand All @@ -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 `<claudeDir>/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)
Expand Down
5 changes: 5 additions & 0 deletions cmd/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
111 changes: 111 additions & 0 deletions cmd/devstate.go
Original file line number Diff line number Diff line change
@@ -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": {"<plugin@marketplace>": {"path": "<dev 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'))
}
60 changes: 59 additions & 1 deletion cmd/repair.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmd/undev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading