diff --git a/cmd/cpm/main.go b/cmd/cpm/main.go index f877adb..c8735d9 100644 --- a/cmd/cpm/main.go +++ b/cmd/cpm/main.go @@ -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" @@ -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 @@ -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 Export installed plugins to a JSON file") + fmt.Println(" -i, --import 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") } diff --git a/internal/claude/client.go b/internal/claude/client.go index b90bc70..5f40e00 100644 --- a/internal/claude/client.go +++ b/internal/claude/client.go @@ -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. diff --git a/internal/claude/export.go b/internal/claude/export.go new file mode 100644 index 0000000..110d9e2 --- /dev/null +++ b/internal/claude/export.go @@ -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 +}