Skip to content
Open
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
154 changes: 139 additions & 15 deletions cmd/cpm/main.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package main

import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/open-cli-collective/cpm/internal/claude"
Expand All @@ -19,35 +21,150 @@ func main() {
}

func run() error {
// Handle --version and --help
if len(os.Args) > 1 {
switch os.Args[1] {
case "--version", "-v":
// Parse flags
exportPath, importPath, done := parseFlags()
if done {
return nil
}

// Check for claude CLI
if _, err := exec.LookPath("claude"); err != nil {
return fmt.Errorf("claude CLI not found in PATH. Please install Claude Code first")
}

client := claude.NewClient()

// Handle export
if exportPath != "" {
return handleExport(client, exportPath)
}

// Handle import
if importPath != "" {
return handleImport(client, importPath)
}

return runTUI(client)
}

// parseFlags parses command-line flags and returns export/import paths.
// Returns done=true if the program should exit (help/version shown).
func parseFlags() (exportPath, importPath string, done bool) {
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
switch {
case arg == "--version" || arg == "-v":
fmt.Println(version.String())
return nil
case "--help", "-h":
return "", "", true
case arg == "--help" || arg == "-h":
printUsage()
return nil
return "", "", true
case arg == "--export" || arg == "-e":
if i+1 >= len(os.Args) {
exitWithError("--export requires a file path argument")
}
i++
exportPath = os.Args[i]
case arg == "--import" || arg == "-i":
if i+1 >= len(os.Args) {
exitWithError("--import requires a file path argument")
}
i++
importPath = os.Args[i]
default:
fmt.Fprintf(os.Stderr, "Unknown option: %s\n\n", os.Args[1])
fmt.Fprintf(os.Stderr, "Unknown option: %s\n\n", arg)
printUsage()
os.Exit(1)
}
}
return exportPath, importPath, false
}

// Check for claude CLI
if _, err := exec.LookPath("claude"); err != nil {
return fmt.Errorf("claude CLI not found in PATH. Please install Claude Code first")
// exitWithError prints an error message and exits.
func exitWithError(msg string) {
fmt.Fprintf(os.Stderr, "Error: %s\n", msg)
os.Exit(1)
}

// handleExport exports installed plugins to a file.
func handleExport(client claude.Client, filePath string) error {
if err := claude.ExportPlugins(client, filePath); err != nil {
return err
}
fmt.Printf("Exported plugins to %s\n", filePath)
return nil
}

// handleImport imports plugins from a file.
func handleImport(client claude.Client, filePath string) error {
exported, err := claude.ReadExportFile(filePath)
if err != nil {
return err
}

if len(exported.Plugins) == 0 {
fmt.Println("No plugins to import.")
return nil
}

// Show what will be imported
fmt.Printf("Plugins to import from %s:\n", filePath)
for _, p := range exported.Plugins {
scope := string(p.Scope)
if scope == "" {
scope = "default"
}
fmt.Printf(" - %s (scope: %s)\n", p.ID, scope)
}
fmt.Println()

// Confirm
fmt.Print("Proceed with import? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Import cancelled.")
return nil
}

// Perform import
result := claude.ImportPlugins(client, exported)

// Print results
if len(result.Installed) > 0 {
fmt.Printf("\nInstalled %d plugin(s):\n", len(result.Installed))
for _, id := range result.Installed {
fmt.Printf(" ✓ %s\n", id)
}
}

if len(result.Skipped) > 0 {
fmt.Printf("\nSkipped %d already-installed plugin(s):\n", len(result.Skipped))
for _, id := range result.Skipped {
fmt.Printf(" - %s\n", id)
}
}

if len(result.Failed) > 0 {
fmt.Printf("\nFailed to install %d plugin(s):\n", len(result.Failed))
for i, id := range result.Failed {
fmt.Printf(" ✗ %s: %v\n", id, result.Errors[i])
}
}

return nil
}

// runTUI runs the interactive TUI.
func runTUI(client claude.Client) error {
// Get current working directory for filtering project-scoped plugins
workingDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}

// Create client and model
client := claude.NewClient()
// Create model
model := tui.NewModel(client, workingDir)

// Run the TUI
Expand All @@ -67,6 +184,13 @@ func printUsage() {
fmt.Println("Usage: cpm [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" -h, --help Show this help message")
fmt.Println(" -v, --version Show version information")
fmt.Println(" -h, --help Show this help message")
fmt.Println(" -v, --version Show version information")
fmt.Println(" -e, --export <file> Export installed plugins to a JSON file")
fmt.Println(" -i, --import <file> Import plugins from a JSON file")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" cpm Launch the interactive TUI")
fmt.Println(" cpm --export plugins.json Export plugins to plugins.json")
fmt.Println(" cpm --import plugins.json Import plugins from plugins.json")
}
19 changes: 15 additions & 4 deletions internal/claude/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,23 @@ func (c *realClient) ListPlugins(includeAvailable bool) (*PluginList, error) {
return nil, fmt.Errorf("claude plugin list failed: %w: %s", err, stderr.String())
}

var list PluginList
if err := json.Unmarshal(stdout.Bytes(), &list); err != nil {
return nil, fmt.Errorf("failed to parse plugin list: %w", err)
// Response format differs based on --available flag:
// - With --available: {"installed": [...], "available": [...]}
// - Without --available: [...] (just installed plugins array)
if includeAvailable {
var list PluginList
if err := json.Unmarshal(stdout.Bytes(), &list); err != nil {
return nil, fmt.Errorf("failed to parse plugin list: %w", err)
}
return &list, nil
}

return &list, nil
// Parse as array of installed plugins
var installed []InstalledPlugin
if err := json.Unmarshal(stdout.Bytes(), &installed); err != nil {
return nil, fmt.Errorf("failed to parse plugin list: %w", err)
}
return &PluginList{Installed: installed}, nil
}

// InstallPlugin implements Client.InstallPlugin.
Expand Down
128 changes: 128 additions & 0 deletions internal/claude/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package claude

import (
"encoding/json"
"fmt"
"os"
)

// ExportVersion is the current export file format version.
const ExportVersion = 1

// ExportedPlugin represents a plugin in an export file.
type ExportedPlugin struct {
ID string `json:"id"`
Scope Scope `json:"scope"`
Enabled bool `json:"enabled"`
}

// ExportFile represents the structure of a plugin export file.
// Fields ordered for optimal memory alignment.
type ExportFile struct {
Plugins []ExportedPlugin `json:"plugins"`
Version int `json:"version"`
}

// ExportPlugins exports the list of installed plugins to a JSON file.
func ExportPlugins(client Client, filePath string) error {
list, err := client.ListPlugins(false)
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}

exported := ExportFile{
Version: ExportVersion,
Plugins: make([]ExportedPlugin, 0, len(list.Installed)),
}

for _, p := range list.Installed {
exported.Plugins = append(exported.Plugins, ExportedPlugin{
ID: p.ID,
Scope: p.Scope,
Enabled: p.Enabled,
})
}

data, err := json.MarshalIndent(exported, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal export data: %w", err)
}

if err := os.WriteFile(filePath, data, 0o600); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}

return nil
}

// ImportResult contains the results of an import operation.
type ImportResult struct {
Installed []string // Plugin IDs that were installed
Skipped []string // Plugin IDs that were already installed
Failed []string // Plugin IDs that failed to install
Errors []error // Errors for failed plugins
}

// ReadExportFile reads and validates a plugin export file.
func ReadExportFile(filePath string) (*ExportFile, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read export file: %w", err)
}

var exported ExportFile
if err := json.Unmarshal(data, &exported); err != nil {
return nil, fmt.Errorf("failed to parse export file: %w", err)
}

if exported.Version != ExportVersion {
return nil, fmt.Errorf("unsupported export file version: %d (expected %d)", exported.Version, ExportVersion)
}

return &exported, nil
}

// ImportPlugins imports plugins from an export file.
// It skips plugins that are already installed.
func ImportPlugins(client Client, exported *ExportFile) *ImportResult {
result := &ImportResult{}

// Get currently installed plugins
list, err := client.ListPlugins(false)
if err != nil {
result.Errors = append(result.Errors, fmt.Errorf("failed to list plugins: %w", err))
return result
}

// Build set of installed plugin IDs
installed := make(map[string]bool)
for _, p := range list.Installed {
installed[p.ID] = true
}

// Install missing plugins
for _, p := range exported.Plugins {
if installed[p.ID] {
result.Skipped = append(result.Skipped, p.ID)
continue
}

if err := client.InstallPlugin(p.ID, p.Scope); err != nil {
result.Failed = append(result.Failed, p.ID)
result.Errors = append(result.Errors, fmt.Errorf("%s: %w", p.ID, err))
continue
}

result.Installed = append(result.Installed, p.ID)

// Handle enabled state - disable if needed
if !p.Enabled {
if err := client.DisablePlugin(p.ID, p.Scope); err != nil {
// Non-fatal: plugin is installed but enable state may differ
result.Errors = append(result.Errors, fmt.Errorf("%s: failed to set enabled state: %w", p.ID, err))
}
}
}

return result
}