summaryrefslogtreecommitdiff
path: root/tools/chain.go
diff options
context:
space:
mode:
Diffstat (limited to 'tools/chain.go')
-rw-r--r--tools/chain.go416
1 files changed, 416 insertions, 0 deletions
diff --git a/tools/chain.go b/tools/chain.go
new file mode 100644
index 0000000..381cc1a
--- /dev/null
+++ b/tools/chain.go
@@ -0,0 +1,416 @@
+package tools
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// Operator represents a chain operator between commands.
+type Operator int
+
+const (
+ OpNone Operator = iota
+ OpAnd // &&
+ OpOr // ||
+ OpSeq // ;
+ OpPipe // |
+)
+
+// Segment is a single command in a chain.
+type Segment struct {
+ Raw string
+ Op Operator // operator AFTER this segment
+}
+
+// ParseChain splits a command string into segments by &&, ;, and |.
+// Respects quoted strings (single and double quotes).
+func ParseChain(input string) []Segment {
+ var segments []Segment
+ var current strings.Builder
+ runes := []rune(input)
+ n := len(runes)
+ for i := 0; i < n; i++ {
+ ch := runes[i]
+ // handle quotes
+ if ch == '\'' || ch == '"' {
+ quote := ch
+ current.WriteRune(ch)
+ i++
+ for i < n && runes[i] != quote {
+ current.WriteRune(runes[i])
+ i++
+ }
+ if i < n {
+ current.WriteRune(runes[i])
+ }
+ continue
+ }
+ // &&
+ if ch == '&' && i+1 < n && runes[i+1] == '&' {
+ segments = append(segments, Segment{
+ Raw: strings.TrimSpace(current.String()),
+ Op: OpAnd,
+ })
+ current.Reset()
+ i++ // skip second &
+ continue
+ }
+ // ;
+ if ch == ';' {
+ segments = append(segments, Segment{
+ Raw: strings.TrimSpace(current.String()),
+ Op: OpSeq,
+ })
+ current.Reset()
+ continue
+ }
+ // ||
+ if ch == '|' && i+1 < n && runes[i+1] == '|' {
+ segments = append(segments, Segment{
+ Raw: strings.TrimSpace(current.String()),
+ Op: OpOr,
+ })
+ current.Reset()
+ i++ // skip second |
+ continue
+ }
+ // | (single pipe)
+ if ch == '|' {
+ segments = append(segments, Segment{
+ Raw: strings.TrimSpace(current.String()),
+ Op: OpPipe,
+ })
+ current.Reset()
+ continue
+ }
+ current.WriteRune(ch)
+ }
+ // last segment
+ last := strings.TrimSpace(current.String())
+ if last != "" {
+ segments = append(segments, Segment{Raw: last, Op: OpNone})
+ }
+ return segments
+}
+
+// ExecChain executes a command string with pipe/chaining support.
+// Returns the combined output of all commands.
+func ExecChain(command string) string {
+ segments := ParseChain(command)
+ if len(segments) == 0 {
+ return "[error] empty command"
+ }
+ var collected []string
+ var lastOutput string
+ var lastErr error
+ pipeInput := ""
+ for i, seg := range segments {
+ if i > 0 {
+ prevOp := segments[i-1].Op
+ // && semantics: skip if previous failed
+ if prevOp == OpAnd && lastErr != nil {
+ continue
+ }
+ // || semantics: skip if previous succeeded
+ if prevOp == OpOr && lastErr == nil {
+ continue
+ }
+ }
+ // determine stdin for this segment
+ segStdin := ""
+ if i == 0 {
+ segStdin = pipeInput
+ } else if segments[i-1].Op == OpPipe {
+ segStdin = lastOutput
+ }
+ lastOutput, lastErr = execSingle(seg.Raw, segStdin)
+ // pipe: output flows to next command's stdin
+ // && or ;: collect output
+ if i < len(segments)-1 && seg.Op == OpPipe {
+ continue
+ }
+ if lastOutput != "" {
+ collected = append(collected, lastOutput)
+ }
+ }
+ return strings.Join(collected, "\n")
+}
+
+// execSingle executes a single command (with arguments) and returns output and error.
+func execSingle(command, stdin string) (string, error) {
+ parts := tokenize(command)
+ if len(parts) == 0 {
+ return "", errors.New("empty command")
+ }
+ name := parts[0]
+ args := parts[1:]
+ // Check if it's a built-in Go command
+ if result, isBuiltin := execBuiltin(name, args, stdin); isBuiltin {
+ return result, nil
+ }
+ // Otherwise execute as system command
+ cmd := exec.Command(name, args...)
+ if stdin != "" {
+ cmd.Stdin = strings.NewReader(stdin)
+ }
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return string(output), err
+ }
+ return string(output), nil
+}
+
+// tokenize splits a command string by whitespace, respecting quotes.
+func tokenize(input string) []string {
+ var tokens []string
+ var current strings.Builder
+ inQuote := false
+ var quoteChar rune
+ for _, ch := range input {
+ if inQuote {
+ if ch == quoteChar {
+ inQuote = false
+ } else {
+ current.WriteRune(ch)
+ }
+ continue
+ }
+ if ch == '\'' || ch == '"' {
+ inQuote = true
+ quoteChar = ch
+ continue
+ }
+ if ch == ' ' || ch == '\t' {
+ if current.Len() > 0 {
+ tokens = append(tokens, current.String())
+ current.Reset()
+ }
+ continue
+ }
+ current.WriteRune(ch)
+ }
+ if current.Len() > 0 {
+ tokens = append(tokens, current.String())
+ }
+ return tokens
+}
+
+// execBuiltin executes a built-in command if it exists.
+// Returns (result, true) if it was a built-in (even if result is empty).
+// Returns ("", false) if it's not a built-in command.
+func execBuiltin(name string, args []string, stdin string) (string, bool) {
+ switch name {
+ case "echo":
+ if stdin != "" {
+ return stdin, true
+ }
+ return strings.Join(args, " "), true
+ case "time":
+ return "2006-01-02 15:04:05 MST", true
+ case "cat":
+ if len(args) == 0 {
+ if stdin != "" {
+ return stdin, true
+ }
+ return "", true
+ }
+ path := args[0]
+ abs := path
+ if !filepath.IsAbs(path) {
+ abs = filepath.Join(cfg.FilePickerDir, path)
+ }
+ data, err := os.ReadFile(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] cat: %v", err), true
+ }
+ return string(data), true
+ case "pwd":
+ return cfg.FilePickerDir, true
+ case "cd":
+ if len(args) == 0 {
+ return "[error] usage: cd <dir>", true
+ }
+ dir := args[0]
+ // Resolve relative to cfg.FilePickerDir
+ abs := dir
+ if !filepath.IsAbs(dir) {
+ abs = filepath.Join(cfg.FilePickerDir, dir)
+ }
+ abs = filepath.Clean(abs)
+ info, err := os.Stat(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] cd: %v", err), true
+ }
+ if !info.IsDir() {
+ return "[error] cd: not a directory: " + dir, true
+ }
+ cfg.FilePickerDir = abs
+ return "Changed directory to: " + cfg.FilePickerDir, true
+ case "mkdir":
+ if len(args) == 0 {
+ return "[error] usage: mkdir [-p] <dir>", true
+ }
+ createParents := false
+ var dirPath string
+ for _, a := range args {
+ if a == "-p" || a == "--parents" {
+ createParents = true
+ } else if dirPath == "" {
+ dirPath = a
+ }
+ }
+ if dirPath == "" {
+ return "[error] usage: mkdir [-p] <dir>", true
+ }
+ abs := dirPath
+ if !filepath.IsAbs(dirPath) {
+ abs = filepath.Join(cfg.FilePickerDir, dirPath)
+ }
+ abs = filepath.Clean(abs)
+ var mkdirFunc func(string, os.FileMode) error
+ if createParents {
+ mkdirFunc = os.MkdirAll
+ } else {
+ mkdirFunc = os.Mkdir
+ }
+ if err := mkdirFunc(abs, 0o755); err != nil {
+ return fmt.Sprintf("[error] mkdir: %v", err), true
+ }
+ if createParents {
+ return "Created " + dirPath + " (with parents)", true
+ }
+ return "Created " + dirPath, true
+ case "ls":
+ dir := "."
+ for _, a := range args {
+ if !strings.HasPrefix(a, "-") {
+ dir = a
+ break
+ }
+ }
+ abs := dir
+ if !filepath.IsAbs(dir) {
+ abs = filepath.Join(cfg.FilePickerDir, dir)
+ }
+ entries, err := os.ReadDir(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] ls: %v", err), true
+ }
+ var out strings.Builder
+ for _, e := range entries {
+ info, _ := e.Info()
+ switch {
+ case e.IsDir():
+ fmt.Fprintf(&out, "d %-8s %s/\n", "-", e.Name())
+ case info != nil:
+ size := info.Size()
+ sizeStr := strconv.FormatInt(size, 10)
+ if size > 1024 {
+ sizeStr = fmt.Sprintf("%.1fKB", float64(size)/1024)
+ }
+ fmt.Fprintf(&out, "f %-8s %s\n", sizeStr, e.Name())
+ default:
+ fmt.Fprintf(&out, "f %-8s %s\n", "?", e.Name())
+ }
+ }
+ if out.Len() == 0 {
+ return "(empty directory)", true
+ }
+ return strings.TrimRight(out.String(), "\n"), true
+ case "go":
+ // Allow all go subcommands
+ if len(args) == 0 {
+ return "[error] usage: go <subcommand> [options]", true
+ }
+ cmd := exec.Command("go", args...)
+ cmd.Dir = cfg.FilePickerDir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Sprintf("[error] go %s: %v\n%s", args[0], err, string(output)), true
+ }
+ return string(output), true
+ case "cp":
+ if len(args) < 2 {
+ return "[error] usage: cp <source> <dest>", true
+ }
+ src := args[0]
+ dst := args[1]
+ if !filepath.IsAbs(src) {
+ src = filepath.Join(cfg.FilePickerDir, src)
+ }
+ if !filepath.IsAbs(dst) {
+ dst = filepath.Join(cfg.FilePickerDir, dst)
+ }
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return fmt.Sprintf("[error] cp: %v", err), true
+ }
+ err = os.WriteFile(dst, data, 0644)
+ if err != nil {
+ return fmt.Sprintf("[error] cp: %v", err), true
+ }
+ return "Copied " + src + " to " + dst, true
+ case "mv":
+ if len(args) < 2 {
+ return "[error] usage: mv <source> <dest>", true
+ }
+ src := args[0]
+ dst := args[1]
+ if !filepath.IsAbs(src) {
+ src = filepath.Join(cfg.FilePickerDir, src)
+ }
+ if !filepath.IsAbs(dst) {
+ dst = filepath.Join(cfg.FilePickerDir, dst)
+ }
+ err := os.Rename(src, dst)
+ if err != nil {
+ return fmt.Sprintf("[error] mv: %v", err), true
+ }
+ return "Moved " + src + " to " + dst, true
+ case "rm":
+ if len(args) == 0 {
+ return "[error] usage: rm [-r] <file>", true
+ }
+ recursive := false
+ var target string
+ for _, a := range args {
+ if a == "-r" || a == "-rf" || a == "-fr" || a == "-recursive" {
+ recursive = true
+ } else if target == "" {
+ target = a
+ }
+ }
+ if target == "" {
+ return "[error] usage: rm [-r] <file>", true
+ }
+ abs := target
+ if !filepath.IsAbs(target) {
+ abs = filepath.Join(cfg.FilePickerDir, target)
+ }
+ info, err := os.Stat(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] rm: %v", err), true
+ }
+ if info.IsDir() {
+ if recursive {
+ err = os.RemoveAll(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] rm: %v", err), true
+ }
+ return "Removed " + abs, true
+ }
+ return "[error] rm: is a directory (use -r)", true
+ }
+ err = os.Remove(abs)
+ if err != nil {
+ return fmt.Sprintf("[error] rm: %v", err), true
+ }
+ return "Removed " + abs, true
+ }
+ return "", false
+}