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
4 changes: 1 addition & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 11 additions & 4 deletions internal/claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Claude CLI Client

Last verified: 2026-02-14
Last verified: 2026-03-07

## Purpose

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
223 changes: 223 additions & 0 deletions internal/claude/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package claude

import (
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
Expand Down Expand Up @@ -385,3 +387,224 @@ func ResolveMarketplaceSourcePath(marketplace string, source any) string {
// Construct full path: ~/.claude/plugins/marketplaces/<marketplace>/<source>
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, &current)
}
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
}
Loading
Loading