package cmd import ( "fmt" "os/exec" "github.com/charmbracelet/log" "github.com/mark3labs/kit/internal/extensions" "github.com/spf13/cobra" ) var ( installLocalFlag bool installUpdateFlag bool installUninstallFlag bool installAllFlag bool ) var installCmd = &cobra.Command{ Use: "install ", Short: "Install extensions from git repositories", Long: `Install extensions from git repositories. The install command downloads and installs Kit extensions from git repositories. Extensions are stored in the global extensions directory by default, or in the project's .kit/git/ directory when using the --local flag. When a repo contains multiple extensions, an interactive multi-select is shown so you can choose which to install. Use --all to skip selection and install everything. Supported URL formats: - github.com/user/repo (shorthand, defaults to HTTPS) - git:github.com/user/repo - https://github.com/user/repo - ssh://git@github.com/user/repo - git@github.com:user/repo You can pin to a specific version, tag, or commit using @: - github.com/user/repo@v1.0.0 - github.com/user/repo@main - github.com/user/repo@abc1234 Examples: kit install github.com/user/my-extension kit install github.com/user/my-extension@v1.0.0 kit install github.com/user/my-extension --local kit install github.com/user/collection --all`, Args: cobra.ExactArgs(1), RunE: runInstall, } func init() { installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory") installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package") installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package") installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting") rootCmd.AddCommand(installCmd) } func runInstall(cmd *cobra.Command, args []string) error { sourceStr := args[0] // Check that git is available if _, err := exec.LookPath("git"); err != nil { return fmt.Errorf("git is not installed or not in PATH") } // Parse the source source, err := extensions.ParseGitSource(sourceStr) if err != nil { return fmt.Errorf("invalid source: %w", err) } // Determine scope scope := extensions.ScopeGlobal if installLocalFlag { scope = extensions.ScopeProject } installer := extensions.NewInstaller(".") // Handle uninstall if installUninstallFlag { return runUninstall(installer, source, scope) } // Handle update if installUpdateFlag { return runUpdate(installer, source, scope) } // Handle install return runInstallPackage(installer, source, scope) } func runInstallPackage(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { // Check if already installed existingScope, installed := installer.IsInstalled(source) if installed { return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope) } // Preview extensions to decide if we need multi-select previews, tempDir, err := installer.PreviewExtensions(source) if err != nil { return fmt.Errorf("previewing extensions: %w", err) } defer extensions.CleanupTempDir(tempDir) if len(previews) == 0 { return fmt.Errorf("no extensions found in %s", source.String()) } scopeStr := "globally" if scope == extensions.ScopeProject { scopeStr = "locally in .kit/git/" } // Single extension or --all flag: install everything directly if len(previews) == 1 || installAllFlag { if err := installer.Install(source, scope); err != nil { return fmt.Errorf("install failed: %w", err) } if source.Pinned { fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr) } else { fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr) } log.Info("extension installed", "source", source.String(), "scope", scope) return nil } // Multiple extensions: show interactive selection includePaths, err := multiSelectForInstall(previews) if err != nil { if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" { fmt.Println("Install cancelled.") return nil } return fmt.Errorf("selection failed: %w", err) } if err := installer.InstallWithInclude(source, scope, includePaths); err != nil { return fmt.Errorf("install failed: %w", err) } fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr) for _, path := range includePaths { fmt.Printf(" - %s\n", path) } log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths)) return nil } func runUpdate(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { // Find the installed package existingScope, installed := installer.IsInstalled(source) if !installed { // Try to find with wildcard (no version) entry, foundScope, err := extensions.FindInManifest(source.Identity()) if err != nil || entry == nil { return fmt.Errorf("extension not installed: %s", source.Identity()) } // Parse the found entry's source foundSource, err := extensions.ParseGitSource(entry.Source) if err != nil { return fmt.Errorf("failed to parse installed source: %w", err) } existingScope = foundScope source = foundSource } // Override scope if specified if installLocalFlag && scope != existingScope { return fmt.Errorf("extension installed in %s scope, cannot update with --local flag", existingScope) } scope = existingScope // Check if pinned if source.Pinned { fmt.Printf("Skipping %s (pinned at %s)\n", source.Identity(), source.Ref) return nil } // Update if err := installer.Update(source, scope); err != nil { return fmt.Errorf("update failed: %w", err) } fmt.Printf("Updated %s\n", source.Identity()) log.Info("extension updated", "source", source.Identity(), "scope", scope) return nil } func runUninstall(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { // Find where it's installed (ignore scope flag for uninstall - remove from wherever it exists) existingScope, installed := installer.IsInstalled(source) if !installed { // Try to find in manifests entry, foundScope, err := extensions.FindInManifest(source.Identity()) if err != nil || entry == nil { return fmt.Errorf("extension not installed: %s", source.Identity()) } existingScope = foundScope // Parse the found entry's source foundSource, err := extensions.ParseGitSource(entry.Source) if err != nil { return fmt.Errorf("failed to parse installed source: %w", err) } source = foundSource } // Uninstall from the scope where it's installed if err := installer.Uninstall(source, existingScope); err != nil { return fmt.Errorf("uninstall failed: %w", err) } fmt.Printf("Uninstalled %s from %s scope\n", source.Identity(), existingScope) log.Info("extension uninstalled", "source", source.Identity(), "scope", existingScope) return nil }