diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..a3354f7 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "pumu/internal/scanner" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List heavy dependency folders (dry-run)", + Long: `Scans for heavy dependency folders and lists them without deleting anything.`, + Example: ` pumu list # scan current directory + pumu list -p ~/dev # scan a custom path`, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Root().PersistentFlags().GetString("path") + if err != nil { + return err + } + return scanner.SweepDir(path, true, false, true) + }, +} diff --git a/cmd/prune.go b/cmd/prune.go new file mode 100644 index 0000000..2a8445f --- /dev/null +++ b/cmd/prune.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "pumu/internal/scanner" + + "github.com/spf13/cobra" +) + +func init() { + // --threshold is prune-specific (local flag) + pruneCmd.Flags().Int("threshold", 50, "Minimum staleness score to prune (0-100)") + // --dry-run is also exposed on root as a persistent flag so other cmds can share it, + // but prune registers its own local copy to avoid double-registration. + pruneCmd.Flags().Bool("dry-run", false, "Only analyze and list, don't delete") + rootCmd.AddCommand(pruneCmd) +} + +var pruneCmd = &cobra.Command{ + Use: "prune", + Short: "Prune dependency folders by staleness score", + Long: `Analyzes dependency folders and removes those whose staleness score +is above the given threshold. Use --dry-run to preview without deleting.`, + Example: ` pumu prune # prune with default threshold (50) + pumu prune --threshold 70 # only prune if score >= 70 + pumu prune --dry-run # preview without deleting + pumu prune --dry-run -p ~/projects # preview on a custom path`, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Root().PersistentFlags().GetString("path") + if err != nil { + return err + } + threshold, err := cmd.Flags().GetInt("threshold") + if err != nil { + return err + } + dryRun, err := cmd.Flags().GetBool("dry-run") + if err != nil { + return err + } + return scanner.PruneDir(path, threshold, dryRun) + }, +} diff --git a/cmd/repair.go b/cmd/repair.go new file mode 100644 index 0000000..45c82fe --- /dev/null +++ b/cmd/repair.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "pumu/internal/scanner" + + "github.com/spf13/cobra" +) + +func init() { + repairCmd.Flags().Bool("verbose", false, "Show details for all projects, including healthy ones") + rootCmd.AddCommand(repairCmd) +} + +var repairCmd = &cobra.Command{ + Use: "repair", + Short: "Repair dependency folders", + Long: `Scans for projects with missing or corrupted dependency folders and reinstalls them.`, + Example: ` pumu repair # repair current directory + pumu repair --verbose # show details for healthy projects too + pumu repair -p ~/projects # repair a custom path`, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Root().PersistentFlags().GetString("path") + if err != nil { + return err + } + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return err + } + return scanner.RepairDir(path, verbose) + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..feab574 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,54 @@ +// Package cmd defines the CLI commands for pumu. +package cmd + +import ( + "fmt" + "os" + + "pumu/internal/scanner" + + "github.com/spf13/cobra" +) + +const version = "v1.2.0-rc.1" + +var rootCmd = &cobra.Command{ + Use: "pumu", + Short: "pumu – clean heavy dependency folders from your projects", + Long: `pumu scans your filesystem for heavy dependency folders +(node_modules, target, .venv, etc.) and lets you sweep, list, +repair or prune them with ease. + +Running pumu with no subcommand refreshes the current directory.`, + Version: version, + Example: ` pumu # refresh current directory + pumu list # list all heavy folders + pumu sweep --no-select # delete all without prompting`, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Flags().GetString("path") + if err != nil { + return err + } + fmt.Printf("Running refresh in %s...\n", path) + return scanner.RefreshCurrentDir() + }, +} + +func init() { + rootCmd.PersistentFlags().StringP("path", "p", ".", "Root path to scan") + + rootCmd.SetVersionTemplate("pumu version {{.Version}}\n") + + // Disable the default completion command output on –completion-script-* + rootCmd.CompletionOptions.DisableDefaultCmd = false +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} diff --git a/cmd/sweep.go b/cmd/sweep.go new file mode 100644 index 0000000..f70eb4f --- /dev/null +++ b/cmd/sweep.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "pumu/internal/scanner" + + "github.com/spf13/cobra" +) + +func init() { + sweepCmd.Flags().Bool("reinstall", false, "Reinstall packages after removing their folders") + sweepCmd.Flags().Bool("no-select", false, "Skip interactive selection (delete/reinstall all found folders)") + rootCmd.AddCommand(sweepCmd) +} + +var sweepCmd = &cobra.Command{ + Use: "sweep", + Short: "Sweep (delete) heavy dependency folders", + Long: `Scans for heavy dependency folders and removes them. +Use --reinstall to automatically reinstall packages after deletion, +and --no-select to skip the interactive selection prompt.`, + Example: ` pumu sweep # interactive selection + pumu sweep --no-select # delete all without prompting + pumu sweep --reinstall # delete and reinstall + pumu sweep --no-select --reinstall ~/projects`, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := cmd.Root().PersistentFlags().GetString("path") + if err != nil { + return err + } + reinstall, err := cmd.Flags().GetBool("reinstall") + if err != nil { + return err + } + noSelect, err := cmd.Flags().GetBool("no-select") + if err != nil { + return err + } + return scanner.SweepDir(path, false, reinstall, noSelect) + }, +} diff --git a/go.mod b/go.mod index eed85bd..4a70ec1 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -24,6 +25,8 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index 40ea9c5..43cb63d 100644 --- a/go.sum +++ b/go.sum @@ -12,10 +12,13 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -36,8 +39,14 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -47,3 +56,4 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 49a6fd0..c9ff9c0 100644 --- a/main.go +++ b/main.go @@ -1,116 +1,8 @@ // Package main is the entry point for the pumu CLI. package main -import ( - "flag" - "fmt" - "os" - - "pumu/internal/scanner" -) - -const version = "v1.2.0-beta.0" +import "pumu/cmd" func main() { - if len(os.Args) < 2 { - runRefresh() - return - } - - switch os.Args[1] { - case "version", "--version", "-v": - fmt.Printf("pumu version %s\n", version) - case "sweep": - runSweep() - case "list": - runList() - case "repair": - runRepair() - case "prune": - runPrune() - case "--help", "-h": - printHelp() - default: - fmt.Printf("Unknown command '%s'. Run 'pumu list', 'pumu sweep', 'pumu repair', 'pumu prune' or just 'pumu'.\n", os.Args[1]) - os.Exit(1) - } -} - -func runRefresh() { - fmt.Println("Running refresh in current directory...") - err := scanner.RefreshCurrentDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func runSweep() { - sweepCmd := flag.NewFlagSet("sweep", flag.ExitOnError) - reinstallFlag := sweepCmd.Bool("reinstall", false, "Reinstall packages after removing their folders") - noSelectFlag := sweepCmd.Bool("no-select", false, "Skip interactive selection (delete/reinstall all found folders)") - if err := sweepCmd.Parse(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) - } - err := scanner.SweepDir(".", false, *reinstallFlag, *noSelectFlag) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func runList() { - listCmd := flag.NewFlagSet("list", flag.ExitOnError) - if err := listCmd.Parse(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) - } - err := scanner.SweepDir(".", true, false, true) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func runRepair() { - repairCmd := flag.NewFlagSet("repair", flag.ExitOnError) - verboseFlag := repairCmd.Bool("verbose", false, "Show details for all projects, including healthy ones") - if err := repairCmd.Parse(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) - } - err := scanner.RepairDir(".", *verboseFlag) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func runPrune() { - pruneCmd := flag.NewFlagSet("prune", flag.ExitOnError) - thresholdFlag := pruneCmd.Int("threshold", 50, "Minimum score to prune (0-100)") - dryRunFlag := pruneCmd.Bool("dry-run", false, "Only analyze and list, don't delete") - if err := pruneCmd.Parse(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) - } - err := scanner.PruneDir(".", *thresholdFlag, *dryRunFlag) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func printHelp() { - fmt.Println("Usage: pumu [options]") - fmt.Println("Commands:") - fmt.Println(" list List heavy dependency folders") - fmt.Println(" sweep Sweep heavy dependency folders") - fmt.Println(" repair Repair dependency folders") - fmt.Println(" prune Prune dependency folders") - fmt.Println(" help Show this help message") - fmt.Println("Options:") - fmt.Println(" -v, --version Print version information") - fmt.Println(" -h, --help Show this help message") + cmd.Execute() }