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
28 changes: 28 additions & 0 deletions cmd/list.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
44 changes: 44 additions & 0 deletions cmd/prune.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
34 changes: 34 additions & 0 deletions cmd/repair.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
54 changes: 54 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
42 changes: 42 additions & 0 deletions cmd/sweep.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
112 changes: 2 additions & 110 deletions main.go
Original file line number Diff line number Diff line change
@@ -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 <command> [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()
}