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..bbc1a05 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,224 @@ 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 { + if i := strings.LastIndexByte(pluginID, '@'); i >= 0 { + 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 ReadKnownMarketplacesFrom(filepath.Join(homeDir, ".claude", "plugins")) +} + +// ReadKnownMarketplacesFrom reads known_marketplaces.json from the given directory. +func ReadKnownMarketplacesFrom(dir string) (map[string]KnownMarketplace, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + defer func() { _ = root.Close() }() + + data, readErr := fs.ReadFile(root.FS(), "known_marketplaces.json") + if readErr != nil { + return nil, readErr + } + + var result map[string]KnownMarketplace + if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil { + return nil, unmarshalErr + } + 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) + + root, err := os.OpenRoot(dir) + if err != nil { + // Directory doesn't exist; no settings to sync. + return nil + } + 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..9a20411 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 := ReadKnownMarketplacesFrom(tmp) + if readErr != nil { + t.Fatalf("ReadKnownMarketplacesFrom: %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.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/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..c00e4b2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -720,11 +720,52 @@ func (m *Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 } +// 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 { + 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 + } + } + 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 nil + } + + 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 + } +} + // 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" diff --git a/version.txt b/version.txt index 3b04cfb..be58634 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.2 +0.3