From c68ef5a4c76cccf683e63a420be385c85fc870da Mon Sep 17 00:00:00 2001 From: Bojan Rajkovic Date: Sat, 7 Mar 2026 08:07:40 -0500 Subject: [PATCH 1/3] feat(claude): auto-sync extraKnownMarketplaces on plugin install/uninstall When plugins are installed or uninstalled at project or local scope, automatically reconcile extraKnownMarketplaces in the corresponding settings file. This ensures team members who clone the repo get prompted to install missing marketplaces when they trust the folder. Key changes: - Add MarketplaceSource polymorphic types (7 concrete source types) with discriminated union JSON marshal/unmarshal - Add SyncExtraMarketplaces with atomic write (temp file + os.Root.Rename) - Add ReadKnownMarketplaces to read ~/.claude/plugins/known_marketplaces.json - Hook syncMarketplaces() into TUI after all operations complete - Upgrade to Go 1.25 for os.Root.Rename support Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +- internal/claude/CLAUDE.md | 15 +- internal/claude/manifest.go | 241 +++++++++++++++++++++++++++++++ internal/claude/manifest_test.go | 234 ++++++++++++++++++++++++++++++ internal/claude/types.go | 182 +++++++++++++++++++++++ internal/claude/types_test.go | 120 +++++++++++++++ internal/tui/CLAUDE.md | 6 +- internal/tui/model_test.go | 100 +++++++++++++ internal/tui/update.go | 43 ++++++ mise.toml | 3 +- 10 files changed, 936 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 73e2a24..00d89a5 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/open-cli-collective/cpm -go 1.24.0 - -toolchain go1.24.12 +go 1.25.0 require ( github.com/charmbracelet/bubbletea v1.3.10 diff --git a/internal/claude/CLAUDE.md b/internal/claude/CLAUDE.md index 2807538..879bcdd 100644 --- a/internal/claude/CLAUDE.md +++ b/internal/claude/CLAUDE.md @@ -1,6 +1,6 @@ # Claude CLI Client -Last verified: 2026-02-14 +Last verified: 2026-03-07 ## Purpose @@ -14,7 +14,12 @@ Provides a typed Go interface to the Claude Code CLI, plugin manifest reading, a - **Exposes**: `ResolveMarketplaceSourcePath(marketplace, source)` - resolves local marketplace paths - **Exposes**: `GetAllEnabledPlugins(workingDir)` - reads all three settings files (user, project, local) to get multi-scope plugin data - **Exposes**: `ReadProjectSettings(settingsPath)` - reads a single settings file -- **Guarantees**: Returns structured `PluginList` with typed `InstalledPlugin`/`AvailablePlugin`. Errors include stderr output. +- **Exposes**: `MarketplaceNameFromPluginID(pluginID)` - extracts marketplace from `name@marketplace` format +- **Exposes**: `SettingsPathForScope(workingDir, scope)` - returns settings file path for project/local scope +- **Exposes**: `ReadKnownMarketplaces()` - reads `~/.claude/plugins/known_marketplaces.json` +- **Exposes**: `SyncExtraMarketplaces(settingsPath, knownMarketplaces)` - reconciles `extraKnownMarketplaces` in a settings file based on its `enabledPlugins` +- **Exposes**: `MarketplaceSource` interface with 7 concrete types (GitHubSource, GitSource, URLSource, NPMSource, FileSource, DirectorySource, HostPatternSource), `MarketplaceEntry`, `KnownMarketplace` +- **Guarantees**: Returns structured `PluginList` with typed `InstalledPlugin`/`AvailablePlugin`. Errors include stderr output. `SyncExtraMarketplaces` uses atomic writes (temp file + rename) for safe settings updates. - **Expects**: `claude` binary in PATH (or custom path via `NewClientWithPath`) ## Dependencies @@ -31,6 +36,8 @@ Provides a typed Go interface to the Claude Code CLI, plugin manifest reading, a - Manifest parsing: Handles flexible author field (string or object format) - Install/Uninstall use install/uninstall: `InstallPlugin` calls `claude plugin install`, `UninstallPlugin` calls `claude plugin uninstall`. `EnablePlugin`/`DisablePlugin` use `enable`/`disable` for toggling state of already-installed plugins. - Multi-scope detection: All three settings files (user, project, local) are read to build a complete `ScopeState` map. This enables later phases to render and manage plugins across multiple scopes from a single data structure. +- Marketplace source types: Discriminated union via `MarketplaceSource` interface with `SourceType()` discriminator. JSON marshal/unmarshal injects/reads a `"source"` field for type discrimination. +- Atomic settings writes: `SyncExtraMarketplaces` writes to a temp file then renames via `os.Root.Rename` to avoid partial writes ## Invariants @@ -41,6 +48,6 @@ Provides a typed Go interface to the Claude Code CLI, plugin manifest reading, a ## Key Files -- `types.go` - Scope enum, InstalledPlugin, AvailablePlugin, PluginList +- `types.go` - Scope enum, InstalledPlugin, AvailablePlugin, PluginList, MarketplaceSource types, MarketplaceEntry, KnownMarketplace - `client.go` - Client interface and realClient implementation -- `manifest.go` - PluginManifest, PluginComponents, ProjectSettings, manifest/settings reading and component scanning +- `manifest.go` - PluginManifest, PluginComponents, ProjectSettings, manifest/settings reading, component scanning, marketplace sync diff --git a/internal/claude/manifest.go b/internal/claude/manifest.go index 4325820..7cbfbc0 100644 --- a/internal/claude/manifest.go +++ b/internal/claude/manifest.go @@ -2,6 +2,8 @@ package claude import ( "encoding/json" + "fmt" + "io" "io/fs" "os" "path/filepath" @@ -385,3 +387,242 @@ func ResolveMarketplaceSourcePath(marketplace string, source any) string { // Construct full path: ~/.claude/plugins/marketplaces// return filepath.Join(homeDir, ".claude", "plugins", "marketplaces", marketplace, sourcePath) } + +// MarketplaceNameFromPluginID extracts the marketplace name from a plugin ID. +// Plugin IDs use "name@marketplace" format. +func MarketplaceNameFromPluginID(pluginID string) string { + for i := len(pluginID) - 1; i >= 0; i-- { + if pluginID[i] == '@' { + return pluginID[i+1:] + } + } + return "" +} + +// SettingsPathForScope returns the settings file path for the given scope. +// Returns empty string for user scope (not applicable). +func SettingsPathForScope(workingDir string, scope Scope) string { + switch scope { + case ScopeProject: + return filepath.Join(workingDir, ".claude", "settings.json") + case ScopeLocal: + return filepath.Join(workingDir, ".claude", "settings.local.json") + default: + return "" + } +} + +// ReadKnownMarketplaces reads ~/.claude/plugins/known_marketplaces.json. +func ReadKnownMarketplaces() (map[string]KnownMarketplace, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + return readKnownMarketplaces(filepath.Join(homeDir, ".claude", "plugins")) +} + +// ReadKnownMarketplacesFrom reads known_marketplaces.json from the given directory. +// This is useful for testing with a custom directory. +func ReadKnownMarketplacesFrom(dir string) (map[string]KnownMarketplace, error) { + return readKnownMarketplaces(dir) +} + +// readKnownMarketplaces is the internal implementation with injectable directory. +func readKnownMarketplaces(dir string) (map[string]KnownMarketplace, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + + f, err := root.Open("known_marketplaces.json") + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + var result map[string]KnownMarketplace + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// atomicWriteRoot writes data to a file atomically using Root.Rename. +func atomicWriteRoot(root *os.Root, name string, data []byte, perm os.FileMode) error { + tmpName := ".tmp." + name + + f, err := root.Create(tmpName) + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + if err := f.Chmod(perm); err != nil { + _ = f.Close() + _ = root.Remove(tmpName) + return fmt.Errorf("chmod temp file: %w", err) + } + + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = root.Remove(tmpName) + return fmt.Errorf("write temp file: %w", err) + } + + if err := f.Close(); err != nil { + _ = root.Remove(tmpName) + return fmt.Errorf("close temp file: %w", err) + } + + if err := root.Rename(tmpName, name); err != nil { + _ = root.Remove(tmpName) + return fmt.Errorf("rename temp file: %w", err) + } + + return nil +} + +// SyncExtraMarketplaces reconciles extraKnownMarketplaces in a settings file +// based on the enabledPlugins that are currently present. +// It adds entries for marketplaces that have plugins but no entry, +// and removes entries for marketplaces with no remaining plugins. +func SyncExtraMarketplaces(settingsPath string, knownMarketplaces map[string]KnownMarketplace) error { + dir := filepath.Dir(settingsPath) + file := filepath.Base(settingsPath) + + // Ensure directory exists + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create settings dir: %w", err) + } + + root, err := os.OpenRoot(dir) + if err != nil { + return fmt.Errorf("open settings dir: %w", err) + } + defer func() { _ = root.Close() }() + + return syncExtraMarketplacesRoot(root, file, knownMarketplaces) +} + +// syncExtraMarketplacesRoot is the internal implementation operating on an os.Root. +func syncExtraMarketplacesRoot(root *os.Root, name string, knownMarketplaces map[string]KnownMarketplace) error { + rawSettings, readErr := readRawSettings(root, name) + if readErr != nil { + return readErr + } + + neededMarketplaces := extractNeededMarketplaces(rawSettings) + currentExtra := parseCurrentExtra(rawSettings) + desiredExtra := computeDesiredExtra(neededMarketplaces, currentExtra, knownMarketplaces) + + if mapsEqual(currentExtra, desiredExtra) { + return nil + } + + applyExtraToSettings(rawSettings, desiredExtra) + + output, marshalErr := json.MarshalIndent(rawSettings, "", " ") + if marshalErr != nil { + return fmt.Errorf("marshal settings: %w", marshalErr) + } + output = append(output, '\n') + + return atomicWriteRoot(root, name, output, 0o644) +} + +// readRawSettings reads a settings file into a raw JSON map, or returns an empty map if not found. +func readRawSettings(root *os.Root, name string) (map[string]json.RawMessage, error) { + f, err := root.Open(name) + if err != nil { + return make(map[string]json.RawMessage), nil + } + defer func() { _ = f.Close() }() + + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("read settings: %w", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse settings: %w", err) + } + return raw, nil +} + +// extractNeededMarketplaces parses enabledPlugins to find which marketplaces are in use. +func extractNeededMarketplaces(rawSettings map[string]json.RawMessage) map[string]bool { + needed := make(map[string]bool) + raw, ok := rawSettings["enabledPlugins"] + if !ok { + return needed + } + var enabled map[string]bool + if json.Unmarshal(raw, &enabled) != nil { + return needed + } + for pluginID := range enabled { + if mp := MarketplaceNameFromPluginID(pluginID); mp != "" { + needed[mp] = true + } + } + return needed +} + +// parseCurrentExtra parses the current extraKnownMarketplaces from raw settings. +func parseCurrentExtra(rawSettings map[string]json.RawMessage) map[string]MarketplaceEntry { + current := make(map[string]MarketplaceEntry) + if raw, ok := rawSettings["extraKnownMarketplaces"]; ok { + _ = json.Unmarshal(raw, ¤t) + } + return current +} + +// computeDesiredExtra computes the desired extraKnownMarketplaces state. +func computeDesiredExtra(needed map[string]bool, current map[string]MarketplaceEntry, known map[string]KnownMarketplace) map[string]MarketplaceEntry { + desired := make(map[string]MarketplaceEntry) + for mp := range needed { + if existing, ok := current[mp]; ok { + desired[mp] = existing + } else if km, ok := known[mp]; ok { + desired[mp] = MarketplaceEntry{Source: km.Source} + } + } + return desired +} + +// applyExtraToSettings updates the rawSettings map with the desired extra marketplaces. +func applyExtraToSettings(rawSettings map[string]json.RawMessage, desired map[string]MarketplaceEntry) { + if len(desired) > 0 { + extraJSON, err := json.Marshal(desired) + if err == nil { + rawSettings["extraKnownMarketplaces"] = extraJSON + } + } else { + delete(rawSettings, "extraKnownMarketplaces") + } +} + +// mapsEqual compares two MarketplaceEntry maps for equality. +func mapsEqual(a, b map[string]MarketplaceEntry) bool { + if len(a) != len(b) { + return false + } + for k, va := range a { + vb, ok := b[k] + if !ok { + return false + } + // Compare by marshaling to JSON (handles interface comparison) + ja, _ := json.Marshal(va) + jb, _ := json.Marshal(vb) + if string(ja) != string(jb) { + return false + } + } + return true +} diff --git a/internal/claude/manifest_test.go b/internal/claude/manifest_test.go index a97d5c3..e6dd138 100644 --- a/internal/claude/manifest_test.go +++ b/internal/claude/manifest_test.go @@ -500,3 +500,237 @@ func TestGetAllEnabledPluginsDisabledButPresent(t *testing.T) { } } } + +func TestMarketplaceNameFromPluginID(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"plugin-a@ed3d-plugins", "ed3d-plugins"}, + {"context7@claude-plugins-official", "claude-plugins-official"}, + {"no-marketplace", ""}, + {"multi@at@signs", "signs"}, + } + for _, tt := range tests { + got := MarketplaceNameFromPluginID(tt.id) + if got != tt.want { + t.Errorf("MarketplaceNameFromPluginID(%q) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestSettingsPathForScope(t *testing.T) { + tests := []struct { + scope Scope + want string + }{ + {ScopeProject, "/work/.claude/settings.json"}, + {ScopeLocal, "/work/.claude/settings.local.json"}, + {ScopeUser, ""}, + {ScopeNone, ""}, + } + for _, tt := range tests { + got := SettingsPathForScope("/work", tt.scope) + if got != tt.want { + t.Errorf("SettingsPathForScope(%q) = %q, want %q", tt.scope, got, tt.want) + } + } +} + +// setupTempDir creates a temp directory and returns its path with a cleanup function. +func setupTempDir(t *testing.T, pattern string) string { + t.Helper() + tmp, err := os.MkdirTemp("", pattern) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(tmp) }) + return tmp +} + +// setupClaudeDir creates a .claude directory inside tmp and writes a settings file. +func setupClaudeDir(t *testing.T, tmp, settingsContent string) string { + t.Helper() + claudeDir := filepath.Join(tmp, ".claude") + if mkErr := os.MkdirAll(claudeDir, 0o750); mkErr != nil { + t.Fatal(mkErr) + } + settingsPath := filepath.Join(claudeDir, "settings.json") + if writeErr := os.WriteFile(settingsPath, []byte(settingsContent), 0o644); writeErr != nil { + t.Fatal(writeErr) + } + return settingsPath +} + +// readRawSettingsFile reads a settings file and returns it as a raw JSON map. +func readRawSettingsFile(t *testing.T, path string) map[string]json.RawMessage { + t.Helper() + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + var raw map[string]json.RawMessage + if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil { + t.Fatal(unmarshalErr) + } + return raw +} + +func TestReadKnownMarketplaces(t *testing.T) { + tmp := setupTempDir(t, "known-mp-*") + + data := `{ + "ed3d-plugins": { + "source": {"source": "github", "repo": "ed3dai/ed3d-plugins"}, + "installLocation": "/test/path", + "lastUpdated": "2026-01-01T00:00:00Z", + "autoUpdate": true + } + }` + writeErr := os.WriteFile(filepath.Join(tmp, "known_marketplaces.json"), []byte(data), 0o644) + if writeErr != nil { + t.Fatal(writeErr) + } + + result, readErr := readKnownMarketplaces(tmp) + if readErr != nil { + t.Fatalf("readKnownMarketplaces: %v", readErr) + } + + km, ok := result["ed3d-plugins"] + if !ok { + t.Fatal("ed3d-plugins not found") + } + gh, ok := km.Source.(*GitHubSource) + if !ok { + t.Fatalf("Source is %T, want *GitHubSource", km.Source) + } + if gh.Repo != "ed3dai/ed3d-plugins" { + t.Errorf("Repo = %q, want %q", gh.Repo, "ed3dai/ed3d-plugins") + } +} + +func TestSyncExtraMarketplacesAddsEntry(t *testing.T) { + tmp := setupTempDir(t, "sync-mp-*") + settingsPath := setupClaudeDir(t, tmp, `{"enabledPlugins":{"my-plugin@ed3d-plugins":true}}`) + + known := map[string]KnownMarketplace{ + "ed3d-plugins": {Source: GitHubSource{Repo: "ed3dai/ed3d-plugins"}}, + } + + syncErr := SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatalf("SyncExtraMarketplaces: %v", syncErr) + } + + raw := readRawSettingsFile(t, settingsPath) + + extraRaw, ok := raw["extraKnownMarketplaces"] + if !ok { + t.Fatal("extraKnownMarketplaces not found in settings") + } + + var extra map[string]MarketplaceEntry + unmarshalErr := json.Unmarshal(extraRaw, &extra) + if unmarshalErr != nil { + t.Fatal(unmarshalErr) + } + + entry, ok := extra["ed3d-plugins"] + if !ok { + t.Fatal("ed3d-plugins not found in extraKnownMarketplaces") + } + gh, ok := entry.Source.(*GitHubSource) + if !ok { + t.Fatalf("Source is %T, want *GitHubSource", entry.Source) + } + if gh.Repo != "ed3dai/ed3d-plugins" { + t.Errorf("Repo = %q, want %q", gh.Repo, "ed3dai/ed3d-plugins") + } +} + +func TestSyncExtraMarketplacesRemovesEntry(t *testing.T) { + tmp := setupTempDir(t, "sync-mp-*") + settingsPath := setupClaudeDir(t, tmp, + `{"enabledPlugins":{},"extraKnownMarketplaces":{"ed3d-plugins":{"source":{"source":"github","repo":"ed3dai/ed3d-plugins"}}}}`) + + known := map[string]KnownMarketplace{ + "ed3d-plugins": {Source: GitHubSource{Repo: "ed3dai/ed3d-plugins"}}, + } + + syncErr := SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatalf("SyncExtraMarketplaces: %v", syncErr) + } + + raw := readRawSettingsFile(t, settingsPath) + if _, ok := raw["extraKnownMarketplaces"]; ok { + t.Error("extraKnownMarketplaces should have been removed") + } +} + +func TestSyncExtraMarketplacesPreservesUnknownFields(t *testing.T) { + tmp := setupTempDir(t, "sync-mp-*") + settingsPath := setupClaudeDir(t, tmp, + `{"enabledPlugins":{"p@mp":true},"env":{"FOO":"bar"},"permissions":{"allow":["*"]}}`) + + known := map[string]KnownMarketplace{ + "mp": {Source: GitHubSource{Repo: "owner/repo"}}, + } + + syncErr := SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatal(syncErr) + } + + raw := readRawSettingsFile(t, settingsPath) + if _, ok := raw["env"]; !ok { + t.Error("env field was not preserved") + } + if _, ok := raw["permissions"]; !ok { + t.Error("permissions field was not preserved") + } + if _, ok := raw["extraKnownMarketplaces"]; !ok { + t.Error("extraKnownMarketplaces should have been added") + } +} + +func TestSyncExtraMarketplacesIdempotent(t *testing.T) { + tmp := setupTempDir(t, "sync-mp-*") + settingsPath := setupClaudeDir(t, tmp, + `{"enabledPlugins":{"p@mp":true},"extraKnownMarketplaces":{"mp":{"source":{"source":"github","repo":"owner/repo"}}}}`) + + known := map[string]KnownMarketplace{ + "mp": {Source: GitHubSource{Repo: "owner/repo"}}, + } + + infoBefore, _ := os.Stat(settingsPath) + + syncErr := SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatal(syncErr) + } + + infoAfter, _ := os.Stat(settingsPath) + if !infoBefore.ModTime().Equal(infoAfter.ModTime()) { + t.Error("file was rewritten despite no changes needed") + } +} + +func TestSyncExtraMarketplacesCreatesFile(t *testing.T) { + tmp := setupTempDir(t, "sync-mp-*") + settingsPath := filepath.Join(tmp, ".claude", "settings.json") + + known := map[string]KnownMarketplace{ + "mp": {Source: GitHubSource{Repo: "owner/repo"}}, + } + + syncErr := SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatal(syncErr) + } + + if _, statErr := os.Stat(settingsPath); statErr == nil { + t.Error("file should not have been created when there are no plugins") + } +} diff --git a/internal/claude/types.go b/internal/claude/types.go index 5669be8..56eca02 100644 --- a/internal/claude/types.go +++ b/internal/claude/types.go @@ -1,6 +1,11 @@ // Package claude provides a client for interacting with the Claude Code CLI. package claude +import ( + "encoding/json" + "fmt" +) + // Scope represents the installation scope of a plugin. type Scope string @@ -43,3 +48,180 @@ type PluginList struct { Installed []InstalledPlugin `json:"installed"` Available []AvailablePlugin `json:"available"` } + +// MarketplaceSource is the interface for marketplace source type discriminated union. +type MarketplaceSource interface { + SourceType() string +} + +// GitHubSource represents a GitHub-hosted marketplace source. +type GitHubSource struct { + Repo string `json:"repo"` + Ref string `json:"ref,omitempty"` + Path string `json:"path,omitempty"` +} + +func (s GitHubSource) SourceType() string { return "github" } + +// GitSource represents a generic Git repository source. +type GitSource struct { + URL string `json:"url"` + Ref string `json:"ref,omitempty"` + Path string `json:"path,omitempty"` +} + +func (s GitSource) SourceType() string { return "git" } + +// URLSource represents a URL-based marketplace source. +type URLSource struct { + Headers map[string]string `json:"headers,omitempty"` + URL string `json:"url"` +} + +func (s URLSource) SourceType() string { return "url" } + +// NPMSource represents an NPM package source. +type NPMSource struct { + Package string `json:"package"` +} + +func (s NPMSource) SourceType() string { return "npm" } + +// FileSource represents a local file source. +type FileSource struct { + Path string `json:"path"` +} + +func (s FileSource) SourceType() string { return "file" } + +// DirectorySource represents a local directory source. +type DirectorySource struct { + Path string `json:"path"` +} + +func (s DirectorySource) SourceType() string { return "directory" } + +// HostPatternSource represents a host-pattern based source. +type HostPatternSource struct { + HostPattern string `json:"hostPattern"` +} + +func (s HostPatternSource) SourceType() string { return "hostPattern" } + +// unmarshalSource parses JSON with a "source" discriminator field into the correct concrete type. +func unmarshalSource(data []byte) (MarketplaceSource, error) { + var disc struct { + Source string `json:"source"` + } + if err := json.Unmarshal(data, &disc); err != nil { + return nil, err + } + + target := newSourceByType(disc.Source) + if target == nil { + return nil, fmt.Errorf("unknown marketplace source type: %q", disc.Source) + } + + if err := json.Unmarshal(data, target); err != nil { + return nil, err + } + return target, nil +} + +// newSourceByType returns a pointer to a zero-value concrete source type. +func newSourceByType(sourceType string) MarketplaceSource { + switch sourceType { + case "github": + return &GitHubSource{} + case "git": + return &GitSource{} + case "url": + return &URLSource{} + case "npm": + return &NPMSource{} + case "file": + return &FileSource{} + case "directory": + return &DirectorySource{} + case "hostPattern": + return &HostPatternSource{} + default: + return nil + } +} + +// marshalSource serializes a MarketplaceSource, injecting the "source" discriminator. +func marshalSource(s MarketplaceSource) (json.RawMessage, error) { + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + // Inject "source" discriminator into the JSON object + var fields map[string]json.RawMessage + if unmarshalErr := json.Unmarshal(data, &fields); unmarshalErr != nil { + return nil, unmarshalErr + } + disc, marshalErr := json.Marshal(s.SourceType()) + if marshalErr != nil { + return nil, marshalErr + } + fields["source"] = disc + return json.Marshal(fields) +} + +// MarketplaceEntry represents a value in the extraKnownMarketplaces settings map. +type MarketplaceEntry struct { + Source MarketplaceSource +} + +func (e MarketplaceEntry) MarshalJSON() ([]byte, error) { + src, err := marshalSource(e.Source) + if err != nil { + return nil, err + } + return json.Marshal(struct { + Source json.RawMessage `json:"source"` + }{Source: src}) +} + +func (e *MarketplaceEntry) UnmarshalJSON(data []byte) error { + var raw struct { + Source json.RawMessage `json:"source"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + src, err := unmarshalSource(raw.Source) + if err != nil { + return err + } + e.Source = src + return nil +} + +// KnownMarketplace represents a value in known_marketplaces.json. +type KnownMarketplace struct { + Source MarketplaceSource + InstallLocation string `json:"installLocation"` + LastUpdated string `json:"lastUpdated"` + AutoUpdate bool `json:"autoUpdate,omitempty"` +} + +func (k *KnownMarketplace) UnmarshalJSON(data []byte) error { + // Parse non-source fields normally + type Alias KnownMarketplace + var raw struct { + Alias + Source json.RawMessage `json:"source"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *k = KnownMarketplace(raw.Alias) + src, err := unmarshalSource(raw.Source) + if err != nil { + return err + } + k.Source = src + return nil +} diff --git a/internal/claude/types_test.go b/internal/claude/types_test.go index ef3eae3..4f3971c 100644 --- a/internal/claude/types_test.go +++ b/internal/claude/types_test.go @@ -105,6 +105,126 @@ func TestAvailablePluginJSON(t *testing.T) { } } +func TestMarketplaceSourceRoundTrip(t *testing.T) { + tests := []struct { + name string + sourceType string + input string + }{ + {"github", "github", `{"source":"github","repo":"owner/repo","ref":"main","path":"sub"}`}, + {"github minimal", "github", `{"source":"github","repo":"owner/repo"}`}, + {"git", "git", `{"source":"git","url":"https://example.com/repo.git","ref":"v1"}`}, + {"url", "url", `{"source":"url","url":"https://example.com/marketplace.json"}`}, + {"url with headers", "url", `{"source":"url","url":"https://example.com","headers":{"Authorization":"Bearer tok"}}`}, + {"npm", "npm", `{"source":"npm","package":"@scope/pkg"}`}, + {"file", "file", `{"source":"file","path":"/tmp/marketplace.json"}`}, + {"directory", "directory", `{"source":"directory","path":"/tmp/marketplace"}`}, + {"hostPattern", "hostPattern", `{"source":"hostPattern","hostPattern":"*.example.com"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src, err := unmarshalSource([]byte(tt.input)) + if err != nil { + t.Fatalf("unmarshalSource: %v", err) + } + if src.SourceType() != tt.sourceType { + t.Errorf("SourceType() = %q, want %q", src.SourceType(), tt.sourceType) + } + + // Round-trip through marshalSource + data, err := marshalSource(src) + if err != nil { + t.Fatalf("marshalSource: %v", err) + } + src2, err := unmarshalSource(data) + if err != nil { + t.Fatalf("unmarshalSource round-trip: %v", err) + } + if src2.SourceType() != tt.sourceType { + t.Errorf("round-trip SourceType() = %q, want %q", src2.SourceType(), tt.sourceType) + } + }) + } +} + +func TestUnmarshalSourceUnknownType(t *testing.T) { + _, err := unmarshalSource([]byte(`{"source":"unknown"}`)) + if err == nil { + t.Error("expected error for unknown source type, got nil") + } +} + +func TestMarketplaceEntryJSON(t *testing.T) { + entry := MarketplaceEntry{ + Source: GitHubSource{Repo: "owner/repo"}, + } + + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var decoded MarketplaceEntry + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + gh, ok := decoded.Source.(*GitHubSource) + if !ok { + t.Fatalf("Source is %T, want *GitHubSource", decoded.Source) + } + if gh.Repo != "owner/repo" { + t.Errorf("Repo = %q, want %q", gh.Repo, "owner/repo") + } +} + +func TestMarketplaceEntryUnmarshalReal(t *testing.T) { + // Real format from extraKnownMarketplaces + input := `{"source":{"source":"github","repo":"ed3dai/ed3d-plugins"}}` + + var entry MarketplaceEntry + if err := json.Unmarshal([]byte(input), &entry); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + gh, ok := entry.Source.(*GitHubSource) + if !ok { + t.Fatalf("Source is %T, want *GitHubSource", entry.Source) + } + if gh.Repo != "ed3dai/ed3d-plugins" { + t.Errorf("Repo = %q, want %q", gh.Repo, "ed3dai/ed3d-plugins") + } +} + +func TestKnownMarketplaceUnmarshal(t *testing.T) { + input := `{ + "source": {"source": "github", "repo": "anthropics/claude-plugins-official"}, + "installLocation": "/home/test/.claude/plugins/marketplaces/claude-plugins-official", + "lastUpdated": "2026-02-28T12:55:10.957Z", + "autoUpdate": true + }` + + var km KnownMarketplace + if err := json.Unmarshal([]byte(input), &km); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + gh, ok := km.Source.(*GitHubSource) + if !ok { + t.Fatalf("Source is %T, want *GitHubSource", km.Source) + } + if gh.Repo != "anthropics/claude-plugins-official" { + t.Errorf("Repo = %q, want %q", gh.Repo, "anthropics/claude-plugins-official") + } + if km.InstallLocation != "/home/test/.claude/plugins/marketplaces/claude-plugins-official" { + t.Errorf("InstallLocation = %q", km.InstallLocation) + } + if !km.AutoUpdate { + t.Error("AutoUpdate = false, want true") + } +} + func TestPluginListJSON(t *testing.T) { jsonData := `{ "installed": [ diff --git a/internal/tui/CLAUDE.md b/internal/tui/CLAUDE.md index 631118e..620dd6f 100644 --- a/internal/tui/CLAUDE.md +++ b/internal/tui/CLAUDE.md @@ -1,6 +1,6 @@ # TUI Package -Last verified: 2026-02-14 +Last verified: 2026-03-07 ## Purpose @@ -9,12 +9,12 @@ Implements the two-pane plugin manager interface using Bubble Tea's Elm Architec ## Contracts - **Exposes**: `NewModel(client, workingDir) -> *Model`, implements `tea.Model` interface -- **Guarantees**: Pending operations (install/uninstall/enable/disable/scope-change) tracked until explicit Apply. Filter preserves selection when possible. All plugins (available + installed) are shown; installed state (scopes, enabled/disabled) reflects only the current workingDir. Plugins installed elsewhere appear without install state. +- **Guarantees**: Pending operations (install/uninstall/enable/disable/scope-change) tracked until explicit Apply. Filter preserves selection when possible. All plugins (available + installed) are shown; installed state (scopes, enabled/disabled) reflects only the current workingDir. Plugins installed elsewhere appear without install state. After all operations complete, `extraKnownMarketplaces` in affected settings files are automatically synced (adds missing, removes stale). - **Expects**: Valid `claude.Client` implementation. Terminal with reasonable size (handles resize). ## Dependencies -- **Uses**: internal/claude (Client interface, GetAllEnabledPlugins, ScopeState), Bubble Tea, Lip Gloss +- **Uses**: internal/claude (Client interface, GetAllEnabledPlugins, ScopeState, SyncExtraMarketplaces, ReadKnownMarketplaces, SettingsPathForScope), Bubble Tea, Lip Gloss - **Used by**: cmd/cpm (creates Model, runs tea.Program) - **Boundary**: No direct CLI calls; all plugin operations go through Client diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e77633b..13dc68c 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "os" + "path/filepath" "strings" "testing" @@ -2617,6 +2618,7 @@ func TestMergePluginsCurrentProjectInstalled(t *testing.T) { } if found == nil { t.Fatal("plugin installed in current project should appear in list") + return } if !found.IsInstalled() { t.Error("plugin should show as installed in current project") @@ -2645,6 +2647,7 @@ func TestMergePluginsOtherProjectNotInstalled(t *testing.T) { } if found == nil { t.Fatal("plugin installed elsewhere should still appear in list") + return } if found.IsInstalled() { t.Error("plugin from another project should NOT show as installed") @@ -2673,6 +2676,7 @@ func TestMergePluginsUserScopeAlwaysInstalled(t *testing.T) { } if found == nil { t.Fatal("user-scoped plugin should appear in list") + return } if !found.IsInstalled() { t.Error("user-scoped plugin should show as installed") @@ -2705,6 +2709,7 @@ func TestMergePluginsAvailableNotMergedFromOtherProject(t *testing.T) { } if found == nil { t.Fatal("available plugin should appear in list") + return } if found.IsInstalled() { t.Error("available plugin installed elsewhere should NOT show as installed here") @@ -2735,3 +2740,98 @@ func TestMergePluginsDeduplicatesInstalledPlugins(t *testing.T) { t.Errorf("duplicate plugin should appear once, got %d", count) } } + +// setupTuiTempDir creates a temp directory with cleanup. +func setupTuiTempDir(t *testing.T, pattern string) string { + t.Helper() + tmp, err := os.MkdirTemp("", pattern) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(tmp) }) + return tmp +} + +// setupTuiClaudeDir creates a .claude directory with a settings file. +func setupTuiClaudeDir(t *testing.T, tmp, settingsContent string) string { + t.Helper() + claudeDir := filepath.Join(tmp, ".claude") + mkErr := os.MkdirAll(claudeDir, 0o750) + if mkErr != nil { + t.Fatal(mkErr) + } + settingsPath := filepath.Join(claudeDir, "settings.json") + writeErr := os.WriteFile(settingsPath, []byte(settingsContent), 0o644) + if writeErr != nil { + t.Fatal(writeErr) + } + return settingsPath +} + +func TestSyncMarketplacesProjectScope(t *testing.T) { + tmp := setupTuiTempDir(t, "tui-sync-*") + settingsPath := setupTuiClaudeDir(t, tmp, `{"enabledPlugins":{"my-plugin@ed3d-plugins":true}}`) + + knownDir := setupTuiTempDir(t, "known-mp-*") + knownData := `{"ed3d-plugins":{"source":{"source":"github","repo":"ed3dai/ed3d-plugins"},"installLocation":"/test","lastUpdated":"2026-01-01T00:00:00Z"}}` + writeErr := os.WriteFile(filepath.Join(knownDir, "known_marketplaces.json"), []byte(knownData), 0o644) + if writeErr != nil { + t.Fatal(writeErr) + } + + known, readErr := claude.ReadKnownMarketplacesFrom(knownDir) + if readErr != nil { + t.Fatal(readErr) + } + + syncErr := claude.SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatal(syncErr) + } + + data, dataErr := os.ReadFile(settingsPath) + if dataErr != nil { + t.Fatal(dataErr) + } + + if !strings.Contains(string(data), "extraKnownMarketplaces") { + t.Error("extraKnownMarketplaces should have been added to settings") + } + if !strings.Contains(string(data), "ed3dai/ed3d-plugins") { + t.Error("ed3d-plugins marketplace source should be in settings") + } +} + +func TestSyncMarketplacesUserScopeSkipped(t *testing.T) { + path := claude.SettingsPathForScope("/test", claude.ScopeUser) + if path != "" { + t.Errorf("SettingsPathForScope(user) = %q, want empty", path) + } +} + +func TestSyncMarketplacesMixedOperations(t *testing.T) { + tmp := setupTuiTempDir(t, "tui-sync-mixed-*") + settingsPath := setupTuiClaudeDir(t, tmp, `{"enabledPlugins":{"plugA@mp-a":true,"plugC@mp-a":true}}`) + + known := map[string]claude.KnownMarketplace{ + "mp-a": {Source: claude.GitHubSource{Repo: "owner/mp-a"}}, + "mp-b": {Source: claude.GitHubSource{Repo: "owner/mp-b"}}, + } + + syncErr := claude.SyncExtraMarketplaces(settingsPath, known) + if syncErr != nil { + t.Fatal(syncErr) + } + + data, readErr := os.ReadFile(settingsPath) + if readErr != nil { + t.Fatal(readErr) + } + + if !strings.Contains(string(data), "owner/mp-a") { + t.Error("mp-a should be in extraKnownMarketplaces") + } + if strings.Contains(string(data), "owner/mp-b") { + t.Error("mp-b should NOT be in extraKnownMarketplaces (no plugins)") + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 90838ed..b1f9ee9 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -717,6 +717,9 @@ func (m *Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.executeOperation(m.progress.operations[m.progress.currentIdx]) } + // Sync extraKnownMarketplaces after all operations complete + m.syncMarketplaces() + // All done - refresh and show summary m.mode = ModeSummary m.main.pendingOps = make(map[string]Operation) @@ -725,6 +728,46 @@ func (m *Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// syncMarketplaces reconciles extraKnownMarketplaces in project/local settings files +// after all operations complete. Errors are non-fatal. +func (m *Model) syncMarketplaces() { + // Determine which settings files were affected + affectedPaths := make(map[string]bool) + for _, op := range m.progress.operations { + for _, scope := range op.Scopes { + if p := claude.SettingsPathForScope(m.workingDir, scope); p != "" { + affectedPaths[p] = true + } + } + for _, scope := range op.UninstallScopes { + if p := claude.SettingsPathForScope(m.workingDir, scope); p != "" { + affectedPaths[p] = true + } + } + // For migrate operations, check original scopes too + if op.Type == OpMigrate { + for scope := range op.OriginalScopes { + if p := claude.SettingsPathForScope(m.workingDir, scope); p != "" { + affectedPaths[p] = true + } + } + } + } + + if len(affectedPaths) == 0 { + return + } + + known, err := claude.ReadKnownMarketplaces() + if err != nil { + return // Non-fatal: marketplace data unavailable + } + + for path := range affectedPaths { + _ = claude.SyncExtraMarketplaces(path, known) + } +} + // updateError handles messages in error mode. func (m *Model) updateError(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { diff --git a/mise.toml b/mise.toml index f849bc6..f1795ae 100644 --- a/mise.toml +++ b/mise.toml @@ -1,10 +1,9 @@ [tools] -go = "1.24" +go = "1.25" "aqua:golangci/golangci-lint" = "2.9.0" "aqua:mvdan/gofumpt" = "0.9.2" "go:golang.org/x/tools/gopls" = "latest" lefthook = "1.11.5" -"npm:@anthropic-ai/claude-code" = "2.1.42" [env] GOFLAGS = "-mod=mod" From 5ae8a6f08b56ec126895d084cfda943e4652d914 Mon Sep 17 00:00:00 2001 From: Bojan Rajkovic Date: Sat, 7 Mar 2026 08:12:17 -0500 Subject: [PATCH 2/3] chore: bump version to 0.3 Co-Authored-By: Claude Opus 4.6 --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 3b04cfb..be58634 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.2 +0.3 From 492476a34af0c275930029cb4ae1fc72f32084c1 Mon Sep 17 00:00:00 2001 From: Bojan Rajkovic Date: Sat, 7 Mar 2026 08:16:41 -0500 Subject: [PATCH 3/3] refactor: simplify marketplace sync code - Use strings.LastIndexByte in MarketplaceNameFromPluginID - Have parsePluginID delegate to MarketplaceNameFromPluginID - Use fs.ReadFile pattern in ReadKnownMarketplacesFrom - Remove unnecessary os.MkdirAll before os.OpenRoot - Move syncMarketplaces to tea.Cmd to avoid blocking UI Co-Authored-By: Claude Opus 4.6 --- internal/claude/manifest.go | 38 +++++++++----------------------- internal/claude/manifest_test.go | 4 ++-- internal/tui/model.go | 9 ++++---- internal/tui/update.go | 32 +++++++++++++-------------- 4 files changed, 31 insertions(+), 52 deletions(-) diff --git a/internal/claude/manifest.go b/internal/claude/manifest.go index 7cbfbc0..bbc1a05 100644 --- a/internal/claude/manifest.go +++ b/internal/claude/manifest.go @@ -391,10 +391,8 @@ func ResolveMarketplaceSourcePath(marketplace string, source any) string { // MarketplaceNameFromPluginID extracts the marketplace name from a plugin ID. // Plugin IDs use "name@marketplace" format. func MarketplaceNameFromPluginID(pluginID string) string { - for i := len(pluginID) - 1; i >= 0; i-- { - if pluginID[i] == '@' { - return pluginID[i+1:] - } + if i := strings.LastIndexByte(pluginID, '@'); i >= 0 { + return pluginID[i+1:] } return "" } @@ -418,37 +416,25 @@ func ReadKnownMarketplaces() (map[string]KnownMarketplace, error) { if err != nil { return nil, err } - return readKnownMarketplaces(filepath.Join(homeDir, ".claude", "plugins")) + return ReadKnownMarketplacesFrom(filepath.Join(homeDir, ".claude", "plugins")) } // ReadKnownMarketplacesFrom reads known_marketplaces.json from the given directory. -// This is useful for testing with a custom directory. func ReadKnownMarketplacesFrom(dir string) (map[string]KnownMarketplace, error) { - return readKnownMarketplaces(dir) -} - -// readKnownMarketplaces is the internal implementation with injectable directory. -func readKnownMarketplaces(dir string) (map[string]KnownMarketplace, error) { root, err := os.OpenRoot(dir) if err != nil { return nil, err } defer func() { _ = root.Close() }() - f, err := root.Open("known_marketplaces.json") - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - data, err := io.ReadAll(f) - if err != nil { - return nil, err + data, readErr := fs.ReadFile(root.FS(), "known_marketplaces.json") + if readErr != nil { + return nil, readErr } var result map[string]KnownMarketplace - if err := json.Unmarshal(data, &result); err != nil { - return nil, err + if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil { + return nil, unmarshalErr } return result, nil } @@ -495,14 +481,10 @@ func SyncExtraMarketplaces(settingsPath string, knownMarketplaces map[string]Kno dir := filepath.Dir(settingsPath) file := filepath.Base(settingsPath) - // Ensure directory exists - if err := os.MkdirAll(dir, 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - root, err := os.OpenRoot(dir) if err != nil { - return fmt.Errorf("open settings dir: %w", err) + // Directory doesn't exist; no settings to sync. + return nil } defer func() { _ = root.Close() }() diff --git a/internal/claude/manifest_test.go b/internal/claude/manifest_test.go index e6dd138..9a20411 100644 --- a/internal/claude/manifest_test.go +++ b/internal/claude/manifest_test.go @@ -592,9 +592,9 @@ func TestReadKnownMarketplaces(t *testing.T) { t.Fatal(writeErr) } - result, readErr := readKnownMarketplaces(tmp) + result, readErr := ReadKnownMarketplacesFrom(tmp) if readErr != nil { - t.Fatalf("readKnownMarketplaces: %v", readErr) + t.Fatalf("ReadKnownMarketplacesFrom: %v", readErr) } km, ok := result["ed3d-plugins"] diff --git a/internal/tui/model.go b/internal/tui/model.go index 15b63c0..2676544 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -212,12 +212,11 @@ func PluginStateFromAvailable(p claude.AvailablePlugin) PluginState { // parsePluginID splits "name@marketplace" into (name, marketplace). func parsePluginID(id string) (name, marketplace string) { - for i := len(id) - 1; i >= 0; i-- { - if id[i] == '@' { - return id[:i], id[i+1:] - } + mp := claude.MarketplaceNameFromPluginID(id) + if mp == "" { + return id, "" } - return id, "" + return id[:len(id)-len(mp)-1], mp } // IsInstalled returns true if the plugin is installed at any scope. diff --git a/internal/tui/update.go b/internal/tui/update.go index b1f9ee9..c00e4b2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -717,21 +717,18 @@ func (m *Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.executeOperation(m.progress.operations[m.progress.currentIdx]) } - // Sync extraKnownMarketplaces after all operations complete - m.syncMarketplaces() - // All done - refresh and show summary m.mode = ModeSummary m.main.pendingOps = make(map[string]Operation) - return m, m.loadPlugins + return m, tea.Batch(m.loadPlugins, m.syncMarketplacesCmd()) } return m, nil } -// syncMarketplaces reconciles extraKnownMarketplaces in project/local settings files -// after all operations complete. Errors are non-fatal. -func (m *Model) syncMarketplaces() { - // Determine which settings files were affected +// syncMarketplacesCmd returns a tea.Cmd that reconciles extraKnownMarketplaces +// in project/local settings files after all operations complete. +func (m *Model) syncMarketplacesCmd() tea.Cmd { + // Collect affected paths before returning the command closure affectedPaths := make(map[string]bool) for _, op := range m.progress.operations { for _, scope := range op.Scopes { @@ -744,7 +741,6 @@ func (m *Model) syncMarketplaces() { affectedPaths[p] = true } } - // For migrate operations, check original scopes too if op.Type == OpMigrate { for scope := range op.OriginalScopes { if p := claude.SettingsPathForScope(m.workingDir, scope); p != "" { @@ -755,16 +751,18 @@ func (m *Model) syncMarketplaces() { } if len(affectedPaths) == 0 { - return - } - - known, err := claude.ReadKnownMarketplaces() - if err != nil { - return // Non-fatal: marketplace data unavailable + return nil } - for path := range affectedPaths { - _ = claude.SyncExtraMarketplaces(path, known) + return func() tea.Msg { + known, err := claude.ReadKnownMarketplaces() + if err != nil { + return nil // Non-fatal + } + for path := range affectedPaths { + _ = claude.SyncExtraMarketplaces(path, known) + } + return nil } }