From 7cbdea3c61fa36336c391ddd31122da97d4d7781 Mon Sep 17 00:00:00 2001 From: Tian Yi Date: Wed, 25 Feb 2026 01:22:14 +0900 Subject: [PATCH 1/4] feat: Add Interactive Shell mode for pikpakcli --- cmd/root.go | 12 +++++ go.mod | 1 + go.sum | 5 ++ internal/shell/shell.go | 112 ++++++++++++++++++++++++++++++++++++++++ main.go | 15 +++++- 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 internal/shell/shell.go diff --git a/cmd/root.go b/cmd/root.go index fca8214..df9bd65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,3 +62,15 @@ func Execute() { os.Exit(1) } } + +// ExecuteShell: Interactive shell +func ExecuteShell(shellStarter func(*cobra.Command)) { + // Init Config + err := conf.InitConfig("config.yml") + if err != nil { + logrus.Errorln(err) + os.Exit(1) + } + // Exec shell + shellStarter(rootCmd) +} diff --git a/go.mod b/go.mod index 3071516..2bfee6f 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect + github.com/chzyer/readline v1.5.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 7b42c75..95a0d76 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -51,6 +55,7 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14= github.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= diff --git a/internal/shell/shell.go b/internal/shell/shell.go new file mode 100644 index 0000000..61cc26e --- /dev/null +++ b/internal/shell/shell.go @@ -0,0 +1,112 @@ +package shell + +import ( + "fmt" + "os" + "strings" + + "github.com/chzyer/readline" + "github.com/spf13/cobra" +) + +// Start starts the interactive shell +func Start(rootCmd *cobra.Command) { + fmt.Println("PikPak CLI Interactive Shell") + fmt.Println("Type 'help' for available commands, 'exit' to quit") + fmt.Println() + + // Create readline instance + // TODO: we can add path here: pikpak {path} >. + l, err := readline.New("pikpak > ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error initializing readline: %v\n", err) + return + } + defer l.Close() + + for { + input, err := l.Readline() + + // Handle EOF (Ctrl+D) + if err == readline.ErrInterrupt { + continue + } + + if err != nil { + // This is EOF + fmt.Println("\nBye~!") + break + } + + input = strings.TrimSpace(input) + + if input == "" { + continue + } + + if input == "exit" || input == "quit" { + fmt.Println("Bye~!") + break + } + + if input == "help" { + rootCmd.Help() + continue + } + + // Parse the args and set them to rootCmd + args := parseShellArgs(input) + rootCmd.SetArgs(args) + + // Directly use pre-defined Execute function + // TODO: Need to be updated if cd command is supported. + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + rootCmd.SetArgs([]string{}) // Reset for next iteration + } +} + +// parseShellArgs parses shell-like arguments +func parseShellArgs(input string) []string { + var args []string + var current strings.Builder + inDoubleQuote := false + inSingleQuote := false + + for i := 0; i < len(input); i++ { + ch := input[i] + + switch ch { + case '"': + if inSingleQuote { + current.WriteByte(ch) + } else { + inDoubleQuote = !inDoubleQuote + } + case '\'': + if inDoubleQuote { + current.WriteByte(ch) + } else { + inSingleQuote = !inSingleQuote + } + case ' ', '\t': + if inDoubleQuote || inSingleQuote { + current.WriteByte(ch) + } else { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + } + default: + current.WriteByte(ch) + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} diff --git a/main.go b/main.go index 51afea3..52c1ab8 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,18 @@ package main -import "github.com/52funny/pikpakcli/cmd" +import ( + "os" + + "github.com/52funny/pikpakcli/cmd" + "github.com/52funny/pikpakcli/internal/shell" +) func main() { - cmd.Execute() + // Check if any args + if len(os.Args) == 1 { + cmd.ExecuteShell(shell.Start) + } else { + // If no arg, execute the command directly + cmd.Execute() + } } From 01a5a103cb53b98df318256cebe3d8bcd9a6414a Mon Sep 17 00:00:00 2001 From: Tian Yi Date: Wed, 18 Mar 2026 15:14:29 +0900 Subject: [PATCH 2/4] Fix flag retention in interactive shell by resetting flags after each command execution --- cmd/list/list.go | 11 +++++++++-- internal/shell/shell.go | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cmd/list/list.go b/cmd/list/list.go index 9828ae2..85d3dfd 100644 --- a/cmd/list/list.go +++ b/cmd/list/list.go @@ -25,7 +25,11 @@ var ListCmd = &cobra.Command{ if err != nil { logrus.Errorln("Login Failed:", err) } - handle(&p, args) + long, _ := cmd.Flags().GetBool("long") + human, _ := cmd.Flags().GetBool("human") + path, _ := cmd.Flags().GetString("path") + parentId, _ := cmd.Flags().GetString("parent-id") + handle(&p, args, long, human, path, parentId) }, } @@ -36,8 +40,11 @@ func init() { ListCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "display the specified parent id") } -func handle(p *pikpak.PikPak, args []string) { +func handle(p *pikpak.PikPak, args []string, long, human bool, path, parentId string) { var err error + if len(args) > 0 { + path = args[0] + } if parentId == "" { parentId, err = p.GetPathFolderId(path) if err != nil { diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 61cc26e..f56c43d 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -7,6 +7,7 @@ import ( "github.com/chzyer/readline" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // Start starts the interactive shell @@ -64,6 +65,9 @@ func Start(rootCmd *cobra.Command) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } rootCmd.SetArgs([]string{}) // Reset for next iteration + + // Reset flags to default values to prevent state retention between commands + resetFlags(rootCmd) } } @@ -107,6 +111,18 @@ func parseShellArgs(input string) []string { if current.Len() > 0 { args = append(args, current.String()) } - return args } + +// resetFlags recursively resets all flags in the command tree to their default values +func resetFlags(cmd *cobra.Command) { + cmd.Flags().VisitAll(func(f *pflag.Flag) { + f.Value.Set(f.DefValue) + }) + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Value.Set(f.DefValue) + }) + for _, subCmd := range cmd.Commands() { + resetFlags(subCmd) + } +} From 16dd9619c329e7620af9979434072e5b52d8f1dc Mon Sep 17 00:00:00 2001 From: Tian Yi Date: Wed, 18 Mar 2026 15:51:53 +0900 Subject: [PATCH 3/4] Implement cd command in interactive shell with path validation, relative/absolute path support, and dynamic prompt update --- internal/shell/shell.go | 64 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index f56c43d..965d22a 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "github.com/52funny/pikpakcli/conf" + "github.com/52funny/pikpakcli/internal/pikpak" "github.com/chzyer/readline" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -16,9 +18,13 @@ func Start(rootCmd *cobra.Command) { fmt.Println("Type 'help' for available commands, 'exit' to quit") fmt.Println() + currentPath := "/" + globalPath := "/" + // globalPath := currentPath + // Create readline instance // TODO: we can add path here: pikpak {path} >. - l, err := readline.New("pikpak > ") + l, err := readline.New(fmt.Sprintf("pikpak %s > ", currentPath)) if err != nil { fmt.Fprintf(os.Stderr, "Error initializing readline: %v\n", err) return @@ -28,6 +34,8 @@ func Start(rootCmd *cobra.Command) { for { input, err := l.Readline() + // l.SetPrompt(fmt.Sprintf("pikpak %s > ", currentPath)) + // Handle EOF (Ctrl+D) if err == readline.ErrInterrupt { continue @@ -55,8 +63,62 @@ func Start(rootCmd *cobra.Command) { continue } + // Handle cd command + if strings.HasPrefix(input, "cd ") { + path := strings.TrimSpace(input[3:]) + // Go back to root if target path is empty, ~ or / + switch path { + case "", "~", "/": + currentPath = "/" + globalPath = currentPath + case "..": + // Go back to parent directory + if currentPath != "/" { + currentPath = currentPath[:strings.LastIndex(currentPath, "/")] + globalPath = currentPath + "/" + if currentPath == "" { + currentPath = "/" + globalPath = currentPath + } + } + + // Update the prompt with the new path + l.SetPrompt(fmt.Sprintf("pikpak %s > ", globalPath)) + default: + // Check if the path exists and is a directory + p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) + err := p.Login() + if err != nil { + fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) + continue + } + targetPath := currentPath + "/" + path + targetPath = strings.ReplaceAll(targetPath, "//", "/") + if targetPath != "/" { + targetPath = strings.TrimSuffix(targetPath, "/") + } + _, err = p.GetPathFolderId(targetPath) + if err != nil { + fmt.Fprintf(os.Stderr, "cd: %s: No such directory\n", targetPath) + continue + } + currentPath = targetPath + globalPath = currentPath + "/" + // Update the prompt with the new path + l.SetPrompt(fmt.Sprintf("pikpak %s > ", globalPath)) + } + + continue + } + // Parse the args and set them to rootCmd args := parseShellArgs(input) + + // For ls command, if no path specified, add current path + if len(args) == 1 && args[0] == "ls" { + args = append(args, "-p", currentPath) + } + rootCmd.SetArgs(args) // Directly use pre-defined Execute function From 541a41dabcff7b04812ac981cc7fbf2378193a27 Mon Sep 17 00:00:00 2001 From: Tian Yi Date: Wed, 18 Mar 2026 15:59:05 +0900 Subject: [PATCH 4/4] Fix cd command arg parsing to properly handle quoted paths with spaces --- internal/shell/shell.go | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 965d22a..1b329db 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -63,9 +63,15 @@ func Start(rootCmd *cobra.Command) { continue } + // Parse the args and set them to rootCmd + args := parseShellArgs(input) + // Handle cd command - if strings.HasPrefix(input, "cd ") { - path := strings.TrimSpace(input[3:]) + if len(args) > 0 && args[0] == "cd" { + var path string + if len(args) > 1 { + path = args[1] + } // Go back to root if target path is empty, ~ or / switch path { case "", "~", "/": @@ -85,6 +91,24 @@ func Start(rootCmd *cobra.Command) { // Update the prompt with the new path l.SetPrompt(fmt.Sprintf("pikpak %s > ", globalPath)) default: + // Handle relative and absolute paths + var targetPath string + if strings.HasPrefix(path, "/") { + // Absolute path + targetPath = path + } else { + // Relative path + if currentPath == "/" { + targetPath = "/" + path + } else { + targetPath = currentPath + "/" + path + } + } + // Normalize path: remove duplicate slashes and trailing slash + targetPath = strings.ReplaceAll(targetPath, "//", "/") + if targetPath != "/" { + targetPath = strings.TrimSuffix(targetPath, "/") + } // Check if the path exists and is a directory p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) err := p.Login() @@ -92,11 +116,6 @@ func Start(rootCmd *cobra.Command) { fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) continue } - targetPath := currentPath + "/" + path - targetPath = strings.ReplaceAll(targetPath, "//", "/") - if targetPath != "/" { - targetPath = strings.TrimSuffix(targetPath, "/") - } _, err = p.GetPathFolderId(targetPath) if err != nil { fmt.Fprintf(os.Stderr, "cd: %s: No such directory\n", targetPath) @@ -111,9 +130,6 @@ func Start(rootCmd *cobra.Command) { continue } - // Parse the args and set them to rootCmd - args := parseShellArgs(input) - // For ls command, if no path specified, add current path if len(args) == 1 && args[0] == "ls" { args = append(args, "-p", currentPath)