prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell_cmds.go (28752B)


      1 package tui
      2 
      3 import (
      4 	"fmt"
      5 	"math/rand"
      6 	"path"
      7 	"regexp"
      8 	"sort"
      9 	"strconv"
     10 	"strings"
     11 	"time"
     12 
     13 	"github.com/charmbracelet/lipgloss"
     14 
     15 	"git.prime8.dev/moncheeta/prime8dev/internal/vfs"
     16 )
     17 
     18 // Commands for the about-page shell. Behaviour follows the web shell
     19 // (internal/web/scripts/modules/shell-cmds.js); writes land in the session's
     20 // in-memory overlay (vfs.FS) and are gone when the SSH session ends.
     21 
     22 // shellCtx is what a command sees: its argv, stdin, where its output goes, and
     23 // whether that is the terminal (styled) or a pipe/file (plain text).
     24 type shellCtx struct {
     25 	s     *shellState
     26 	fs    *vfs.FS
     27 	env   shellEnv
     28 	argv  []string
     29 	stdin string
     30 	tty   bool
     31 	out   func(string)
     32 	err   func(string)
     33 	res   *shellResult
     34 }
     35 
     36 func (c *shellCtx) resolve(p string) string { return vfs.Resolve(c.s.home, c.s.cwd, p) }
     37 
     38 func (c *shellCtx) display(abs string) string { return vfs.DisplayPath(c.s.home, abs) }
     39 
     40 type shellCmd func(c *shellCtx) int
     41 
     42 var shellUsage = map[string]string{
     43 	"cat":      "cat [-n] [file...]        print files (or stdin)",
     44 	"cd":       "cd [dir|-]                change directory (~ is home, - is the last one)",
     45 	"clear":    "clear                     clear the screen",
     46 	"cp":       "cp [-r] src... dest       copy files and directories",
     47 	"date":     "date                      print the date and time",
     48 	"echo":     "echo [-n] [-e] [text...]  print text",
     49 	"env":      "env                       print environment variables",
     50 	"exit":     "exit                      close the session",
     51 	"export":   "export [NAME=value...]    set environment variables",
     52 	"false":    "false                     do nothing, unsuccessfully",
     53 	"fetch":    "fetch                     system info, the same card as the top of the page",
     54 	"grep":     "grep [-invcrlwFq] pattern [file...]  search for a regex",
     55 	"head":     "head [-n N] [file...]     first N lines (default 10)",
     56 	"help":     "help                      list commands",
     57 	"history":  "history [-c]              command history (-c clears it)",
     58 	"hostname": "hostname                  print the host name",
     59 	"id":       "id                        print user and group ids",
     60 	"ls":       "ls [-a1d] [path...]       list directory contents",
     61 	"man":      "man command               show usage for a command",
     62 	"mkdir":    "mkdir [-p] dir...         make directories",
     63 	"mv":       "mv src... dest            move or rename",
     64 	"pwd":      "pwd                       print working directory",
     65 	"restore":  "restore                   drop your changes, back to the original files",
     66 	"rm":       "rm [-rf] path...          remove files and directories",
     67 	"rmdir":    "rmdir dir...              remove empty directories",
     68 	"sort":     "sort [-rnu] [file...]     sort lines",
     69 	"tail":     "tail [-n N] [file...]     last N lines (default 10)",
     70 	"tee":      "tee [-a] file...          copy stdin to files and stdout",
     71 	"touch":    "touch file...             create files",
     72 	"tree":     "tree [-a] [path]          draw a directory tree",
     73 	"true":     "true                      do nothing, successfully",
     74 	"type":     "type name...              say what a command is",
     75 	"uname":    "uname [-a]                print system information",
     76 	"uniq":     "uniq [-c] [file...]       collapse repeated lines",
     77 	"unset":    "unset NAME...             remove environment variables",
     78 	"uptime":   "uptime                    how long this session has been up",
     79 	"wc":       "wc [-lwc] [file...]       count lines, words and bytes",
     80 	"which":    "which command...          locate a command",
     81 	"whoami":   "whoami                    print the user name",
     82 }
     83 
     84 const shellHelpText = `prime8 shell. what you create here lasts until you disconnect.
     85 
     86   navigate   ls cd pwd tree
     87   read       cat head tail wc grep sort uniq
     88   write      echo touch mkdir cp mv rm rmdir tee
     89   system     fetch whoami id hostname uname date uptime env export
     90   session    clear history man restore exit
     91 
     92 pipes |, redirects > >> < 2>, ; && ||, globs * ? [ ], $VARS and ~ all work.
     93 man <cmd> for usage. try: cat about, tree projects, echo hi > notes && cat notes`
     94 
     95 var shellBuiltins = map[string]bool{
     96 	"cd": true, "echo": true, "exit": true, "logout": true, "export": true, "history": true,
     97 	"pwd": true, "type": true, "unset": true, "help": true, "true": true, "false": true,
     98 }
     99 
    100 var shellCommandTable map[string]shellCmd
    101 
    102 // shellCommands is every command name, sorted, for completion.
    103 var shellCommands []string
    104 
    105 func init() {
    106 	shellCommandTable = map[string]shellCmd{
    107 		"cat": cmdCat, "cd": cmdCd, "clear": cmdClear, "cp": cmdCp, "date": cmdDate,
    108 		"echo": cmdEcho, "env": cmdEnv, "exit": cmdExit, "logout": cmdExit, "export": cmdExport,
    109 		"false": func(*shellCtx) int { return 1 }, "fetch": cmdFetch, "grep": cmdGrep,
    110 		"head": func(c *shellCtx) int { return headTail(c, false) }, "help": cmdHelp,
    111 		"history": cmdHistory, "hostname": cmdHostname, "id": cmdID, "ls": cmdLs, "man": cmdMan,
    112 		"mkdir": cmdMkdir, "mv": cmdMv, "pwd": cmdPwd, "restore": cmdRestore, "rm": cmdRm,
    113 		"rmdir": cmdRmdir, "sort": cmdSort, "sudo": cmdSudo,
    114 		"tail": func(c *shellCtx) int { return headTail(c, true) }, "tee": cmdTee,
    115 		"touch": cmdTouch, "tree": cmdTree, "true": func(*shellCtx) int { return 0 },
    116 		"type": cmdType, "uname": cmdUname, "uniq": cmdUniq, "unset": cmdUnset,
    117 		"uptime": cmdUptime, "wc": cmdWc, "which": cmdWhich, "whoami": cmdWhoami,
    118 	}
    119 	for name := range shellCommandTable {
    120 		shellCommands = append(shellCommands, name)
    121 	}
    122 	sort.Strings(shellCommands)
    123 }
    124 
    125 // --- helpers ---
    126 
    127 // shellFlags splits leading -abc options from operands. Letters in withValue
    128 // take the next argument (or the rest of the cluster) as their value.
    129 func shellFlags(args []string, withValue string) (map[byte]string, []string) {
    130 	set := map[byte]string{}
    131 	var rest []string
    132 	i := 0
    133 	for ; i < len(args); i++ {
    134 		a := args[i]
    135 		if a == "--" {
    136 			i++
    137 			break
    138 		}
    139 		if !strings.HasPrefix(a, "-") || a == "-" || (len(a) > 1 && a[1] >= '0' && a[1] <= '9') {
    140 			rest = append(rest, a)
    141 			continue
    142 		}
    143 		for j := 1; j < len(a); j++ {
    144 			f := a[j]
    145 			if strings.IndexByte(withValue, f) >= 0 {
    146 				v := a[j+1:]
    147 				if v == "" && i+1 < len(args) {
    148 					i++
    149 					v = args[i]
    150 				}
    151 				set[f] = v
    152 				break
    153 			}
    154 			set[f] = "1"
    155 		}
    156 	}
    157 	rest = append(rest, args[i:]...)
    158 	return set, rest
    159 }
    160 
    161 func has(set map[byte]string, f byte) bool { _, ok := set[f]; return ok }
    162 
    163 func textLines(text string) []string {
    164 	if text == "" {
    165 		return nil
    166 	}
    167 	ls := strings.Split(text, "\n")
    168 	if ls[len(ls)-1] == "" {
    169 		ls = ls[:len(ls)-1]
    170 	}
    171 	return ls
    172 }
    173 
    174 func joinLines(ls []string) string {
    175 	if len(ls) == 0 {
    176 		return ""
    177 	}
    178 	return strings.Join(ls, "\n") + "\n"
    179 }
    180 
    181 // fileText is a file's content as a text stream: the generated files carry
    182 // no trailing newline, so add one like a real text file would have.
    183 func fileText(n *vfs.Node) string {
    184 	if n.Content == "" || strings.HasSuffix(n.Content, "\n") {
    185 		return n.Content
    186 	}
    187 	return n.Content + "\n"
    188 }
    189 
    190 type shellInput struct{ name, text string }
    191 
    192 // readInputs yields each file operand's text, or stdin when there are none.
    193 func readInputs(c *shellCtx, files []string) []shellInput {
    194 	if len(files) == 0 {
    195 		return []shellInput{{"", c.stdin}}
    196 	}
    197 	var out []shellInput
    198 	for _, f := range files {
    199 		if f == "-" {
    200 			out = append(out, shellInput{"-", c.stdin})
    201 			continue
    202 		}
    203 		if c.resolve(f) == "/dev/null" {
    204 			out = append(out, shellInput{f, ""})
    205 			continue
    206 		}
    207 		n := c.fs.Get(c.resolve(f))
    208 		switch {
    209 		case n == nil:
    210 			c.err(c.argv[0] + ": " + f + ": No such file or directory\n")
    211 		case n.Dir:
    212 			c.err(c.argv[0] + ": " + f + ": Is a directory\n")
    213 		default:
    214 			out = append(out, shellInput{f, fileText(n)})
    215 		}
    216 	}
    217 	return out
    218 }
    219 
    220 func inputsStatus(inputs []shellInput, files []string) int {
    221 	if len(inputs) == max(len(files), 1) {
    222 		return 0
    223 	}
    224 	return 1
    225 }
    226 
    227 func (c *shellCtx) name(n *vfs.Node, label string) string {
    228 	if c.tty && n != nil && n.Dir {
    229 		return shellDirStyle.Render(label)
    230 	}
    231 	return label
    232 }
    233 
    234 func errText(err error) string {
    235 	if err == nil {
    236 		return ""
    237 	}
    238 	return err.Error()
    239 }
    240 
    241 // writeFile creates, replaces or appends to a file through the overlay.
    242 func writeFile(c *shellCtx, p, content string, appendTo bool) error {
    243 	abs := c.resolve(p)
    244 	if abs == "/dev/null" {
    245 		return nil
    246 	}
    247 	n := c.fs.Get(abs)
    248 	if n != nil && n.Dir {
    249 		return vfs.ErrIsDir
    250 	}
    251 	if err := c.fs.CheckCreate(abs); err != nil {
    252 		return err
    253 	}
    254 	if appendTo && n != nil {
    255 		content = n.Content + content
    256 	}
    257 	return c.fs.Write(abs, content)
    258 }
    259 
    260 func pad2(n int) string { return fmt.Sprintf("%02d", n) }
    261 
    262 // --- commands ---
    263 
    264 func cmdHelp(c *shellCtx) int {
    265 	c.out(shellHelpText + "\n")
    266 	return 0
    267 }
    268 
    269 func cmdMan(c *shellCtx) int {
    270 	if len(c.argv) < 2 {
    271 		c.err("What manual page do you want?\nFor example, try 'man man'.\n")
    272 		return 1
    273 	}
    274 	name := c.argv[1]
    275 	u, ok := shellUsage[name]
    276 	if name == "man" {
    277 		u, ok = shellUsage["man"], true
    278 	}
    279 	if !ok {
    280 		c.err("No manual entry for " + name + "\n")
    281 		return 16
    282 	}
    283 	c.out(strings.ToUpper(name) + "(1)\n\n  " + u + "\n")
    284 	return 0
    285 }
    286 
    287 func cmdPwd(c *shellCtx) int      { c.out(c.s.cwd + "\n"); return 0 }
    288 func cmdWhoami(c *shellCtx) int   { c.out(c.s.user + "\n"); return 0 }
    289 func cmdHostname(c *shellCtx) int { c.out("prime8\n"); return 0 }
    290 
    291 func cmdID(c *shellCtx) int {
    292 	u := c.s.user
    293 	c.out(fmt.Sprintf("uid=1000(%s) gid=1000(%s) groups=1000(%s)\n", u, u, u))
    294 	return 0
    295 }
    296 
    297 func cmdUname(c *shellCtx) int {
    298 	set, _ := shellFlags(c.argv[1:], "")
    299 	if has(set, 'a') {
    300 		c.out("Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux\n")
    301 	} else {
    302 		c.out("Linux\n")
    303 	}
    304 	return 0
    305 }
    306 
    307 func cmdFetch(c *shellCtx) int {
    308 	if c.env.fetch == nil {
    309 		c.err("fetch: no system info\n")
    310 		return 1
    311 	}
    312 	card := c.env.fetch(c.tty)
    313 	if strings.TrimSpace(card) == "" {
    314 		c.err("fetch: no system info\n")
    315 		return 1
    316 	}
    317 	c.out(card + "\n")
    318 	return 0
    319 }
    320 
    321 func shellNow(c *shellCtx) time.Time {
    322 	if c.env.now.IsZero() {
    323 		return time.Now()
    324 	}
    325 	return c.env.now
    326 }
    327 
    328 func cmdDate(c *shellCtx) int {
    329 	c.out(shellNow(c).Format("Mon Jan _2 15:04:05 MST 2006") + "\n")
    330 	return 0
    331 }
    332 
    333 func cmdUptime(c *shellCtx) int {
    334 	now := shellNow(c)
    335 	mins := 0
    336 	if !c.s.started.IsZero() {
    337 		mins = int(now.Sub(c.s.started).Minutes())
    338 	}
    339 	up := fmt.Sprintf("%d min", mins)
    340 	if mins >= 60 {
    341 		up = fmt.Sprintf("%d:%s", mins/60, pad2(mins%60))
    342 	}
    343 	load := func() string { return fmt.Sprintf("%.2f", rand.Float64()*0.2) }
    344 	c.out(fmt.Sprintf(" %s up %s,  1 user,  load average: %s, %s, %s\n", now.Format("15:04:05"), up, load(), load(), load()))
    345 	return 0
    346 }
    347 
    348 func cmdEcho(c *shellCtx) int {
    349 	args := c.argv[1:]
    350 	newline, escapes := true, false
    351 	for len(args) > 0 && echoFlagRE.MatchString(args[0]) {
    352 		if strings.Contains(args[0], "n") {
    353 			newline = false
    354 		}
    355 		if strings.Contains(args[0], "e") {
    356 			escapes = true
    357 		}
    358 		if strings.Contains(args[0], "E") {
    359 			escapes = false
    360 		}
    361 		args = args[1:]
    362 	}
    363 	s := strings.Join(args, " ")
    364 	if escapes {
    365 		s = strings.NewReplacer(`\n`, "\n", `\t`, "\t", `\r`, "\r", `\\`, `\`).Replace(s)
    366 	}
    367 	if newline {
    368 		s += "\n"
    369 	}
    370 	c.out(s)
    371 	return 0
    372 }
    373 
    374 func cmdEnv(c *shellCtx) int {
    375 	env := c.s.envMap()
    376 	keys := make([]string, 0, len(env))
    377 	for k := range env {
    378 		keys = append(keys, k)
    379 	}
    380 	sort.Strings(keys)
    381 	for _, k := range keys {
    382 		c.out(k + "=" + env[k] + "\n")
    383 	}
    384 	return 0
    385 }
    386 
    387 var shellAssignRE = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
    388 
    389 func cmdExport(c *shellCtx) int {
    390 	if len(c.argv) == 1 {
    391 		return cmdEnv(c)
    392 	}
    393 	status := 0
    394 	for _, a := range c.argv[1:] {
    395 		if m := shellAssignRE.FindStringSubmatch(a); m != nil {
    396 			c.s.setVar(m[1], m[2])
    397 		} else if !shellAssignRE.MatchString(a + "=") {
    398 			c.err("export: `" + a + "': not a valid identifier\n")
    399 			status = 1
    400 		}
    401 	}
    402 	return status
    403 }
    404 
    405 func cmdUnset(c *shellCtx) int {
    406 	for _, a := range c.argv[1:] {
    407 		delete(c.s.vars, a)
    408 	}
    409 	return 0
    410 }
    411 
    412 func cmdHistory(c *shellCtx) int {
    413 	set, _ := shellFlags(c.argv[1:], "")
    414 	if has(set, 'c') {
    415 		c.s.cmdHistory = nil
    416 		return 0
    417 	}
    418 	for i, l := range c.s.cmdHistory {
    419 		c.out(fmt.Sprintf("%5d  %s\n", i+1, l))
    420 	}
    421 	return 0
    422 }
    423 
    424 func cmdClear(c *shellCtx) int {
    425 	if c.tty {
    426 		c.res.clear = true
    427 	}
    428 	return 0
    429 }
    430 
    431 func cmdExit(c *shellCtx) int {
    432 	c.res.quit = true
    433 	return 0
    434 }
    435 
    436 func cmdSudo(c *shellCtx) int {
    437 	c.err(c.s.user + " is not in the sudoers file. This incident will be reported.\n")
    438 	return 1
    439 }
    440 
    441 func cmdRestore(c *shellCtx) int {
    442 	n := c.s.overlay.Changes()
    443 	c.s.overlay.Reset()
    444 	c.out(fmt.Sprintf("restored the original files (%d change%s dropped)\n", n, plural(n, "", "s")))
    445 	return 0
    446 }
    447 
    448 func plural(n int, one, many string) string {
    449 	if n == 1 {
    450 		return one
    451 	}
    452 	return many
    453 }
    454 
    455 func cmdWhich(c *shellCtx) int {
    456 	status := 0
    457 	for _, a := range c.argv[1:] {
    458 		switch {
    459 		case shellBuiltins[a]:
    460 			c.out(a + ": shell built-in command\n")
    461 		case shellCommandTable[a] != nil:
    462 			c.out("/usr/bin/" + a + "\n")
    463 		default:
    464 			status = 1
    465 		}
    466 	}
    467 	return status
    468 }
    469 
    470 func cmdType(c *shellCtx) int {
    471 	status := 0
    472 	for _, a := range c.argv[1:] {
    473 		switch {
    474 		case shellBuiltins[a]:
    475 			c.out(a + " is a shell builtin\n")
    476 		case shellCommandTable[a] != nil:
    477 			c.out(a + " is /usr/bin/" + a + "\n")
    478 		default:
    479 			c.err("type: " + a + ": not found\n")
    480 			status = 1
    481 		}
    482 	}
    483 	return status
    484 }
    485 
    486 func cmdCd(c *shellCtx) int {
    487 	args := c.argv[1:]
    488 	if len(args) > 1 {
    489 		c.err("cd: too many arguments\n")
    490 		return 1
    491 	}
    492 	target := ""
    493 	if len(args) == 1 {
    494 		target = args[0]
    495 	}
    496 	if target == "-" {
    497 		if c.s.oldpwd == "" {
    498 			c.err("cd: OLDPWD not set\n")
    499 			return 1
    500 		}
    501 		target = c.s.oldpwd
    502 		c.out(target + "\n")
    503 	}
    504 	abs := c.resolve(target)
    505 	n := c.fs.Get(abs)
    506 	switch {
    507 	case n == nil:
    508 		c.err("cd: " + target + ": No such file or directory\n")
    509 		return 1
    510 	case !n.Dir:
    511 		c.err("cd: " + target + ": Not a directory\n")
    512 		return 1
    513 	}
    514 	c.s.oldpwd, c.s.cwd = c.s.cwd, abs
    515 	return 0
    516 }
    517 
    518 func cmdLs(c *shellCtx) int {
    519 	set, rest := shellFlags(c.argv[1:], "")
    520 	all := has(set, 'a') || has(set, 'A')
    521 	perLine := has(set, '1') || !c.tty
    522 	if len(rest) == 0 {
    523 		rest = []string{"."}
    524 	}
    525 	status := 0
    526 	show := func(names []string, nodes []*vfs.Node) {
    527 		if len(names) == 0 {
    528 			return
    529 		}
    530 		parts := make([]string, len(names))
    531 		for i := range names {
    532 			label := names[i]
    533 			if c.tty && nodes[i].Dir {
    534 				label += "/"
    535 			}
    536 			parts[i] = c.name(nodes[i], label)
    537 		}
    538 		if perLine {
    539 			c.out(strings.Join(parts, "\n") + "\n")
    540 		} else {
    541 			c.out(strings.Join(parts, "  ") + "\n")
    542 		}
    543 	}
    544 	var fileNames []string
    545 	var fileNodes []*vfs.Node
    546 	type dirArg struct {
    547 		p string
    548 		n *vfs.Node
    549 	}
    550 	var dirs []dirArg
    551 	for _, p := range rest {
    552 		n := c.fs.Get(c.resolve(p))
    553 		switch {
    554 		case n == nil:
    555 			c.err("ls: cannot access '" + p + "': No such file or directory\n")
    556 			status = 2
    557 		case n.Dir && !has(set, 'd'):
    558 			dirs = append(dirs, dirArg{p, n})
    559 		default:
    560 			fileNames = append(fileNames, p)
    561 			fileNodes = append(fileNodes, n)
    562 		}
    563 	}
    564 	show(fileNames, fileNodes)
    565 	headers := len(dirs)+len(fileNames) > 1
    566 	for i, d := range dirs {
    567 		if i > 0 || len(fileNames) > 0 {
    568 			c.out("\n")
    569 		}
    570 		if headers {
    571 			c.out(d.p + ":\n")
    572 		}
    573 		var names []string
    574 		var nodes []*vfs.Node
    575 		for _, k := range d.n.SortedChildren() {
    576 			if !all && strings.HasPrefix(k.Name, ".") {
    577 				continue
    578 			}
    579 			names = append(names, k.Name)
    580 			nodes = append(nodes, k)
    581 		}
    582 		show(names, nodes)
    583 	}
    584 	return status
    585 }
    586 
    587 func cmdCat(c *shellCtx) int {
    588 	set, rest := shellFlags(c.argv[1:], "")
    589 	inputs := readInputs(c, rest)
    590 	var b strings.Builder
    591 	for _, in := range inputs {
    592 		b.WriteString(in.text)
    593 	}
    594 	text := b.String()
    595 	if has(set, 'n') {
    596 		ls := textLines(text)
    597 		for i, l := range ls {
    598 			ls[i] = fmt.Sprintf("%6d\t%s", i+1, l)
    599 		}
    600 		text = joinLines(ls)
    601 	}
    602 	c.out(text)
    603 	return inputsStatus(inputs, rest)
    604 }
    605 
    606 func cmdTree(c *shellCtx) int {
    607 	set, rest := shellFlags(c.argv[1:], "")
    608 	p := "."
    609 	if len(rest) > 0 {
    610 		p = rest[0]
    611 	}
    612 	root := c.fs.Get(c.resolve(p))
    613 	if root == nil {
    614 		c.err(p + " [error opening dir]\n\n0 directories, 0 files\n")
    615 		return 2
    616 	}
    617 	dirs, files := 0, 0
    618 	var b strings.Builder
    619 	b.WriteString(c.name(root, p) + "\n")
    620 	var walk func(n *vfs.Node, prefix string)
    621 	walk = func(n *vfs.Node, prefix string) {
    622 		var kids []*vfs.Node
    623 		for _, k := range n.SortedChildren() {
    624 			if has(set, 'a') || !strings.HasPrefix(k.Name, ".") {
    625 				kids = append(kids, k)
    626 			}
    627 		}
    628 		for i, k := range kids {
    629 			last := i == len(kids)-1
    630 			branch, next := "├── ", "│   "
    631 			if last {
    632 				branch, next = "└── ", "    "
    633 			}
    634 			b.WriteString(prefix + branch + c.name(k, k.Name) + "\n")
    635 			if k.Dir {
    636 				dirs++
    637 				walk(k, prefix+next)
    638 			} else {
    639 				files++
    640 			}
    641 		}
    642 	}
    643 	if root.Dir {
    644 		walk(root, "")
    645 	}
    646 	fmt.Fprintf(&b, "\n%d director%s, %d file%s\n", dirs, plural(dirs, "y", "ies"), files, plural(files, "", "s"))
    647 	c.out(b.String())
    648 	return 0
    649 }
    650 
    651 func headTail(c *shellCtx, tail bool) int {
    652 	args := make([]string, len(c.argv)-1)
    653 	for i, a := range c.argv[1:] {
    654 		if headCountRE.MatchString(a) {
    655 			a = "-n" + a[1:]
    656 		}
    657 		args[i] = a
    658 	}
    659 	set, rest := shellFlags(args, "n")
    660 	n := 10
    661 	if v, ok := set['n']; ok {
    662 		x, err := strconv.Atoi(v)
    663 		if err != nil || x < 0 {
    664 			c.err(c.argv[0] + ": invalid number of lines: '" + v + "'\n")
    665 			return 1
    666 		}
    667 		n = x
    668 	}
    669 	inputs := readInputs(c, rest)
    670 	for i, in := range inputs {
    671 		if len(inputs) > 1 {
    672 			if i > 0 {
    673 				c.out("\n")
    674 			}
    675 			c.out("==> " + in.name + " <==\n")
    676 		}
    677 		ls := textLines(in.text)
    678 		if tail {
    679 			ls = ls[max(len(ls)-n, 0):]
    680 		} else {
    681 			ls = ls[:min(n, len(ls))]
    682 		}
    683 		c.out(joinLines(ls))
    684 	}
    685 	return inputsStatus(inputs, rest)
    686 }
    687 
    688 func cmdWc(c *shellCtx) int {
    689 	set, rest := shellFlags(c.argv[1:], "")
    690 	pickL, pickW, pickC := has(set, 'l'), has(set, 'w'), has(set, 'c')
    691 	if !pickL && !pickW && !pickC {
    692 		pickL, pickW, pickC = true, true, true
    693 	}
    694 	inputs := readInputs(c, rest)
    695 	type row struct {
    696 		l, w, b int
    697 		name    string
    698 	}
    699 	var rows []row
    700 	for _, in := range inputs {
    701 		rows = append(rows, row{strings.Count(in.text, "\n"), len(strings.Fields(in.text)), len(in.text), in.name})
    702 	}
    703 	if len(rows) > 1 {
    704 		t := row{name: "total"}
    705 		for _, r := range rows {
    706 			t.l, t.w, t.b = t.l+r.l, t.w+r.w, t.b+r.b
    707 		}
    708 		rows = append(rows, t)
    709 	}
    710 	cols := 0
    711 	for _, p := range []bool{pickL, pickW, pickC} {
    712 		if p {
    713 			cols++
    714 		}
    715 	}
    716 	width := 1
    717 	if len(rest) == 0 && cols > 1 {
    718 		width = 7
    719 	}
    720 	for _, r := range rows {
    721 		for _, v := range []int{r.l, r.w, r.b} {
    722 			width = max(width, len(strconv.Itoa(v)))
    723 		}
    724 	}
    725 	for _, r := range rows {
    726 		var parts []string
    727 		if pickL {
    728 			parts = append(parts, fmt.Sprintf("%*d", width, r.l))
    729 		}
    730 		if pickW {
    731 			parts = append(parts, fmt.Sprintf("%*d", width, r.w))
    732 		}
    733 		if pickC {
    734 			parts = append(parts, fmt.Sprintf("%*d", width, r.b))
    735 		}
    736 		line := strings.Join(parts, " ")
    737 		if r.name != "" && r.name != "-" {
    738 			line += " " + r.name
    739 		}
    740 		c.out(line + "\n")
    741 	}
    742 	return inputsStatus(inputs, rest)
    743 }
    744 
    745 var shellMatchStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
    746 
    747 func cmdGrep(c *shellCtx) int {
    748 	set, rest := shellFlags(c.argv[1:], "e")
    749 	pattern, ok := set['e']
    750 	if !ok {
    751 		if len(rest) == 0 {
    752 			c.err("Usage: grep [OPTION]... PATTERNS [FILE]...\n")
    753 			return 2
    754 		}
    755 		pattern, rest = rest[0], rest[1:]
    756 	}
    757 	src := pattern
    758 	if has(set, 'F') {
    759 		src = regexp.QuoteMeta(pattern)
    760 	}
    761 	if has(set, 'w') {
    762 		src = `\b(?:` + src + `)\b`
    763 	}
    764 	if has(set, 'i') {
    765 		src = "(?i)" + src
    766 	}
    767 	re, err := regexp.Compile(src)
    768 	if err != nil {
    769 		lit := regexp.QuoteMeta(pattern)
    770 		if has(set, 'i') {
    771 			lit = "(?i)" + lit
    772 		}
    773 		re = regexp.MustCompile(lit)
    774 	}
    775 	recursive := has(set, 'r') || has(set, 'R')
    776 	var inputs []shellInput
    777 	errs := false
    778 	if len(rest) == 0 {
    779 		if recursive {
    780 			rest = []string{"."}
    781 		} else {
    782 			inputs = append(inputs, shellInput{"", c.stdin})
    783 		}
    784 	}
    785 	var collect func(label string, n *vfs.Node)
    786 	collect = func(label string, n *vfs.Node) {
    787 		if !n.Dir {
    788 			inputs = append(inputs, shellInput{label, fileText(n)})
    789 			return
    790 		}
    791 		for _, k := range n.SortedChildren() {
    792 			collect(label+"/"+k.Name, k)
    793 		}
    794 	}
    795 	for _, f := range rest {
    796 		n := c.fs.Get(c.resolve(f))
    797 		switch {
    798 		case n == nil:
    799 			if !has(set, 's') {
    800 				c.err("grep: " + f + ": No such file or directory\n")
    801 			}
    802 			errs = true
    803 		case n.Dir && !recursive:
    804 			c.err("grep: " + f + ": Is a directory\n")
    805 			errs = true
    806 		default:
    807 			label := strings.TrimRight(f, "/")
    808 			if label == "" {
    809 				label = f
    810 			}
    811 			collect(label, n)
    812 		}
    813 	}
    814 	status := 1
    815 	prefix := len(inputs) > 1 || recursive
    816 	invert := has(set, 'v')
    817 	for _, in := range inputs {
    818 		count := 0
    819 		for i, l := range textLines(in.text) {
    820 			if re.MatchString(l) == invert {
    821 				continue
    822 			}
    823 			count++
    824 			status = 0
    825 			if has(set, 'l') || has(set, 'c') || has(set, 'q') {
    826 				continue
    827 			}
    828 			head := ""
    829 			if prefix {
    830 				head += in.name + ":"
    831 			}
    832 			if has(set, 'n') {
    833 				head += strconv.Itoa(i+1) + ":"
    834 			}
    835 			if c.tty && head != "" {
    836 				head = promptPathStyle.Render(head)
    837 			}
    838 			if has(set, 'o') && !invert {
    839 				for _, m := range re.FindAllString(l, -1) {
    840 					if m == "" {
    841 						continue
    842 					}
    843 					if c.tty {
    844 						m = shellMatchStyle.Render(m)
    845 					}
    846 					c.out(head + m + "\n")
    847 				}
    848 				continue
    849 			}
    850 			if c.tty && !invert {
    851 				l = re.ReplaceAllStringFunc(l, func(m string) string {
    852 					if m == "" {
    853 						return m
    854 					}
    855 					return shellMatchStyle.Render(m)
    856 				})
    857 			}
    858 			c.out(head + l + "\n")
    859 		}
    860 		if has(set, 'c') {
    861 			label := ""
    862 			if prefix {
    863 				label = in.name + ":"
    864 			}
    865 			c.out(label + strconv.Itoa(count) + "\n")
    866 		} else if has(set, 'l') && count > 0 {
    867 			c.out(in.name + "\n")
    868 		}
    869 	}
    870 	if errs && status != 0 {
    871 		return 2
    872 	}
    873 	return status
    874 }
    875 
    876 func cmdSort(c *shellCtx) int {
    877 	set, rest := shellFlags(c.argv[1:], "")
    878 	inputs := readInputs(c, rest)
    879 	var ls []string
    880 	for _, in := range inputs {
    881 		ls = append(ls, textLines(in.text)...)
    882 	}
    883 	if has(set, 'n') {
    884 		num := func(s string) float64 {
    885 			f, _ := strconv.ParseFloat(strings.Fields(s + " x")[0], 64)
    886 			return f
    887 		}
    888 		sort.SliceStable(ls, func(i, j int) bool { return num(ls[i]) < num(ls[j]) })
    889 	} else {
    890 		sort.Strings(ls)
    891 	}
    892 	if has(set, 'r') {
    893 		for i, j := 0, len(ls)-1; i < j; i, j = i+1, j-1 {
    894 			ls[i], ls[j] = ls[j], ls[i]
    895 		}
    896 	}
    897 	if has(set, 'u') {
    898 		var u []string
    899 		for i, l := range ls {
    900 			if i == 0 || l != ls[i-1] {
    901 				u = append(u, l)
    902 			}
    903 		}
    904 		ls = u
    905 	}
    906 	c.out(joinLines(ls))
    907 	return inputsStatus(inputs, rest)
    908 }
    909 
    910 func cmdUniq(c *shellCtx) int {
    911 	set, rest := shellFlags(c.argv[1:], "")
    912 	inputs := readInputs(c, rest)
    913 	var ls []string
    914 	for _, in := range inputs {
    915 		ls = append(ls, textLines(in.text)...)
    916 	}
    917 	var out []string
    918 	for i := 0; i < len(ls); {
    919 		j := i
    920 		for j < len(ls) && ls[j] == ls[i] {
    921 			j++
    922 		}
    923 		if has(set, 'c') {
    924 			out = append(out, fmt.Sprintf("%7d %s", j-i, ls[i]))
    925 		} else {
    926 			out = append(out, ls[i])
    927 		}
    928 		i = j
    929 	}
    930 	c.out(joinLines(out))
    931 	return inputsStatus(inputs, rest)
    932 }
    933 
    934 func cmdTee(c *shellCtx) int {
    935 	set, rest := shellFlags(c.argv[1:], "")
    936 	status := 0
    937 	for _, f := range rest {
    938 		if err := writeFile(c, f, c.stdin, has(set, 'a')); err != nil {
    939 			c.err("tee: " + f + ": " + errText(err) + "\n")
    940 			status = 1
    941 		}
    942 	}
    943 	c.out(c.stdin)
    944 	return status
    945 }
    946 
    947 func cmdTouch(c *shellCtx) int {
    948 	_, rest := shellFlags(c.argv[1:], "")
    949 	if len(rest) == 0 {
    950 		c.err("touch: missing file operand\n")
    951 		return 1
    952 	}
    953 	status := 0
    954 	for _, f := range rest {
    955 		abs := c.resolve(f)
    956 		if abs == "/dev/null" {
    957 			continue
    958 		}
    959 		if n := c.fs.Get(abs); n != nil {
    960 			if !c.fs.Writable(abs) {
    961 				c.err("touch: cannot touch '" + f + "': Permission denied\n")
    962 				status = 1
    963 			}
    964 			continue
    965 		}
    966 		err := c.fs.CheckCreate(abs)
    967 		if err == nil {
    968 			err = c.fs.Write(abs, "")
    969 		}
    970 		if err != nil {
    971 			c.err("touch: cannot touch '" + f + "': " + errText(err) + "\n")
    972 			status = 1
    973 		}
    974 	}
    975 	return status
    976 }
    977 
    978 func cmdMkdir(c *shellCtx) int {
    979 	set, rest := shellFlags(c.argv[1:], "")
    980 	if len(rest) == 0 {
    981 		c.err("mkdir: missing operand\n")
    982 		return 1
    983 	}
    984 	status := 0
    985 	for _, d := range rest {
    986 		abs := c.resolve(d)
    987 		fail := func(err error) {
    988 			c.err("mkdir: cannot create directory '" + d + "': " + errText(err) + "\n")
    989 			status = 1
    990 		}
    991 		if has(set, 'p') {
    992 			cur := ""
    993 			for _, part := range strings.Split(strings.Trim(abs, "/"), "/") {
    994 				if part == "" {
    995 					continue
    996 				}
    997 				cur += "/" + part
    998 				n := c.fs.Get(cur)
    999 				if n != nil && n.Dir {
   1000 					continue
   1001 				}
   1002 				var err error
   1003 				if n != nil {
   1004 					err = vfs.ErrExist
   1005 				} else if err = c.fs.CheckCreate(cur); err == nil {
   1006 					err = c.fs.Mkdir(cur)
   1007 				}
   1008 				if err != nil {
   1009 					fail(err)
   1010 					break
   1011 				}
   1012 			}
   1013 			continue
   1014 		}
   1015 		var err error
   1016 		if c.fs.Get(abs) != nil {
   1017 			err = vfs.ErrExist
   1018 		} else if err = c.fs.CheckCreate(abs); err == nil {
   1019 			err = c.fs.Mkdir(abs)
   1020 		}
   1021 		if err != nil {
   1022 			fail(err)
   1023 		}
   1024 	}
   1025 	return status
   1026 }
   1027 
   1028 func cmdRmdir(c *shellCtx) int {
   1029 	_, rest := shellFlags(c.argv[1:], "")
   1030 	if len(rest) == 0 {
   1031 		c.err("rmdir: missing operand\n")
   1032 		return 1
   1033 	}
   1034 	status := 0
   1035 	for _, d := range rest {
   1036 		abs := c.resolve(d)
   1037 		n := c.fs.Get(abs)
   1038 		var err error
   1039 		switch {
   1040 		case n == nil:
   1041 			err = vfs.ErrNotExist
   1042 		case !n.Dir:
   1043 			err = vfs.ErrNotDir
   1044 		case len(n.Children) > 0:
   1045 			err = vfs.ErrNotEmpty
   1046 		default:
   1047 			if err = c.fs.CheckCreate(abs); err == nil {
   1048 				err = c.fs.Remove(abs)
   1049 			}
   1050 		}
   1051 		if err != nil {
   1052 			c.err("rmdir: failed to remove '" + d + "': " + errText(err) + "\n")
   1053 			status = 1
   1054 		}
   1055 	}
   1056 	return status
   1057 }
   1058 
   1059 var (
   1060 	echoFlagRE  = regexp.MustCompile(`^-[neE]+$`)
   1061 	headCountRE = regexp.MustCompile(`^-\d+$`)
   1062 )
   1063 
   1064 var dotPathRE = regexp.MustCompile(`(^|/)\.\.?/*$`)
   1065 
   1066 func cmdRm(c *shellCtx) int {
   1067 	set, rest := shellFlags(c.argv[1:], "")
   1068 	recursive := has(set, 'r') || has(set, 'R')
   1069 	force := has(set, 'f')
   1070 	if len(rest) == 0 {
   1071 		if force {
   1072 			return 0
   1073 		}
   1074 		c.err("rm: missing operand\n")
   1075 		return 1
   1076 	}
   1077 	status := 0
   1078 	for _, p := range rest {
   1079 		abs := c.resolve(p)
   1080 		if dotPathRE.MatchString(p) {
   1081 			c.err("rm: refusing to remove '.' or '..' directory: skipping '" + p + "'\n")
   1082 			status = 1
   1083 			continue
   1084 		}
   1085 		if abs == "/" && recursive {
   1086 			c.err("rm: it is dangerous to operate recursively on '/'\nrm: use --no-preserve-root to override this failsafe\n")
   1087 			status = 1
   1088 			continue
   1089 		}
   1090 		n := c.fs.Get(abs)
   1091 		if n == nil {
   1092 			if !force {
   1093 				c.err("rm: cannot remove '" + p + "': No such file or directory\n")
   1094 				status = 1
   1095 			}
   1096 			continue
   1097 		}
   1098 		if n.Dir && !recursive {
   1099 			c.err("rm: cannot remove '" + p + "': Is a directory\n")
   1100 			status = 1
   1101 			continue
   1102 		}
   1103 		err := c.fs.CheckCreate(abs)
   1104 		if err == nil {
   1105 			err = c.fs.Remove(abs)
   1106 		}
   1107 		if err != nil {
   1108 			c.err("rm: cannot remove '" + p + "': " + errText(err) + "\n")
   1109 			status = 1
   1110 		}
   1111 	}
   1112 	return status
   1113 }
   1114 
   1115 func cmdCp(c *shellCtx) int { return transfer(c, false) }
   1116 func cmdMv(c *shellCtx) int { return transfer(c, true) }
   1117 
   1118 func transfer(c *shellCtx, move bool) int {
   1119 	name := c.argv[0]
   1120 	set, rest := shellFlags(c.argv[1:], "")
   1121 	recursive := has(set, 'r') || has(set, 'R') || has(set, 'a')
   1122 	if len(rest) < 2 {
   1123 		if len(rest) == 1 {
   1124 			c.err(name + ": missing destination file operand after '" + rest[0] + "'\n")
   1125 		} else {
   1126 			c.err(name + ": missing file operand\n")
   1127 		}
   1128 		return 1
   1129 	}
   1130 	destArg := rest[len(rest)-1]
   1131 	srcs := rest[:len(rest)-1]
   1132 	destAbs := c.resolve(destArg)
   1133 	destNode := c.fs.Get(destAbs)
   1134 	destIsDir := destNode != nil && destNode.Dir
   1135 	if len(srcs) > 1 && !destIsDir {
   1136 		c.err(name + ": target '" + destArg + "': Not a directory\n")
   1137 		return 1
   1138 	}
   1139 	verb := "create"
   1140 	if move {
   1141 		verb = "move"
   1142 	}
   1143 	status := 0
   1144 	for _, src := range srcs {
   1145 		srcAbs := c.resolve(src)
   1146 		node := c.fs.Get(srcAbs)
   1147 		fail := func(msg string) {
   1148 			c.err(name + ": " + msg + "\n")
   1149 			status = 1
   1150 		}
   1151 		if node == nil {
   1152 			fail("cannot stat '" + src + "': No such file or directory")
   1153 			continue
   1154 		}
   1155 		if node.Dir && !move && !recursive {
   1156 			fail("-r not specified; omitting directory '" + src + "'")
   1157 			continue
   1158 		}
   1159 		target := destAbs
   1160 		if destIsDir {
   1161 			target = path.Join(destAbs, path.Base(srcAbs))
   1162 		}
   1163 		if target == srcAbs {
   1164 			fail("'" + src + "' and '" + destArg + "' are the same file")
   1165 			continue
   1166 		}
   1167 		if node.Dir && strings.HasPrefix(target+"/", srcAbs+"/") {
   1168 			op := "copy"
   1169 			if move {
   1170 				op = "move"
   1171 			}
   1172 			fail("cannot " + op + " '" + src + "' to a subdirectory of itself, '" + c.display(target) + "'")
   1173 			continue
   1174 		}
   1175 		existing := c.fs.Get(target)
   1176 		if existing != nil && existing.Dir && !node.Dir {
   1177 			fail("cannot overwrite directory '" + c.display(target) + "' with non-directory")
   1178 			continue
   1179 		}
   1180 		if existing != nil && !existing.Dir && node.Dir {
   1181 			fail("cannot overwrite non-directory '" + c.display(target) + "' with directory '" + src + "'")
   1182 			continue
   1183 		}
   1184 		err := c.fs.CheckCreate(target)
   1185 		if err == nil && move {
   1186 			err = c.fs.CheckCreate(srcAbs)
   1187 		}
   1188 		if err == nil {
   1189 			err = c.fs.Copy(node, target)
   1190 		}
   1191 		if err == nil && move {
   1192 			err = c.fs.Remove(srcAbs)
   1193 		}
   1194 		if err != nil {
   1195 			fail("cannot " + verb + " '" + src + "': " + errText(err))
   1196 		}
   1197 	}
   1198 	return status
   1199 }