just-bash 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +112 -7
  4. just_bash/commands/cat/cat.py +5 -1
  5. just_bash/commands/echo/echo.py +33 -1
  6. just_bash/commands/grep/grep.py +30 -1
  7. just_bash/commands/od/od.py +144 -30
  8. just_bash/commands/printf/printf.py +289 -87
  9. just_bash/commands/pwd/pwd.py +32 -2
  10. just_bash/commands/read/read.py +243 -64
  11. just_bash/commands/readlink/readlink.py +3 -9
  12. just_bash/commands/registry.py +24 -0
  13. just_bash/commands/rmdir/__init__.py +5 -0
  14. just_bash/commands/rmdir/rmdir.py +160 -0
  15. just_bash/commands/sed/sed.py +142 -31
  16. just_bash/commands/stat/stat.py +9 -0
  17. just_bash/commands/time/__init__.py +5 -0
  18. just_bash/commands/time/time.py +74 -0
  19. just_bash/commands/touch/touch.py +118 -8
  20. just_bash/commands/whoami/__init__.py +5 -0
  21. just_bash/commands/whoami/whoami.py +18 -0
  22. just_bash/fs/in_memory_fs.py +22 -0
  23. just_bash/fs/overlay_fs.py +14 -0
  24. just_bash/interpreter/__init__.py +1 -1
  25. just_bash/interpreter/builtins/__init__.py +2 -0
  26. just_bash/interpreter/builtins/control.py +4 -8
  27. just_bash/interpreter/builtins/declare.py +321 -24
  28. just_bash/interpreter/builtins/getopts.py +163 -0
  29. just_bash/interpreter/builtins/let.py +2 -2
  30. just_bash/interpreter/builtins/local.py +71 -5
  31. just_bash/interpreter/builtins/misc.py +22 -6
  32. just_bash/interpreter/builtins/readonly.py +38 -10
  33. just_bash/interpreter/builtins/set.py +58 -8
  34. just_bash/interpreter/builtins/test.py +136 -19
  35. just_bash/interpreter/builtins/unset.py +62 -10
  36. just_bash/interpreter/conditionals.py +29 -4
  37. just_bash/interpreter/control_flow.py +61 -17
  38. just_bash/interpreter/expansion.py +1647 -104
  39. just_bash/interpreter/interpreter.py +424 -70
  40. just_bash/interpreter/types.py +263 -2
  41. just_bash/parser/__init__.py +2 -0
  42. just_bash/parser/lexer.py +295 -26
  43. just_bash/parser/parser.py +523 -64
  44. just_bash/types.py +11 -0
  45. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  46. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
  47. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
@@ -31,6 +31,59 @@ from dataclasses import dataclass
31
31
  from ...types import CommandContext, ExecResult
32
32
 
33
33
 
34
+ def _bre_to_python(pattern: str) -> str:
35
+ """Convert a BRE (Basic Regular Expression) pattern to Python ERE.
36
+
37
+ In BRE: +, ?, (, ), {, }, | are literal; \\+, \\?, \\(, \\), \\{, \\}, \\| are special.
38
+ In Python ERE: +, ?, (, ), {, }, | are special; \\+, \\?, etc. are literal.
39
+ """
40
+ result = []
41
+ i = 0
42
+ in_bracket = False
43
+ while i < len(pattern):
44
+ ch = pattern[i]
45
+ if in_bracket:
46
+ result.append(ch)
47
+ if ch == "]" and i > 0:
48
+ in_bracket = False
49
+ i += 1
50
+ continue
51
+ if ch == "[":
52
+ in_bracket = True
53
+ result.append(ch)
54
+ i += 1
55
+ # Handle ] as first char in bracket (literal)
56
+ if i < len(pattern) and pattern[i] == "^":
57
+ result.append(pattern[i])
58
+ i += 1
59
+ if i < len(pattern) and pattern[i] == "]":
60
+ result.append(pattern[i])
61
+ i += 1
62
+ continue
63
+ if ch == "\\" and i + 1 < len(pattern):
64
+ nxt = pattern[i + 1]
65
+ if nxt in "+?(){}|":
66
+ # BRE \+ \? \( \) \{ \} \| → ERE special
67
+ result.append(nxt)
68
+ i += 2
69
+ continue
70
+ else:
71
+ # Other escape sequences pass through
72
+ result.append(ch)
73
+ result.append(nxt)
74
+ i += 2
75
+ continue
76
+ if ch in "+?(){}|":
77
+ # Literal in BRE → escape for Python ERE
78
+ result.append("\\")
79
+ result.append(ch)
80
+ i += 1
81
+ continue
82
+ result.append(ch)
83
+ i += 1
84
+ return "".join(result)
85
+
86
+
34
87
  @dataclass
35
88
  class SedAddress:
36
89
  """Represents a sed address."""
@@ -48,6 +101,7 @@ class SedCommand_:
48
101
 
49
102
  cmd: str # s, d, p, a, i, y, q, h, H, g, G, x, n, N, etc.
50
103
  address: SedAddress | None = None
104
+ negate: bool = False # ! address negation
51
105
  pattern: re.Pattern | None = None
52
106
  replacement: str | None = None
53
107
  flags: str = ""
@@ -258,18 +312,55 @@ class SedCommand:
258
312
  commands: list[SedCommand_] = []
259
313
 
260
314
  for script in scripts:
261
- # Handle multiple commands separated by semicolons or newlines
262
- for cmd_str in re.split(r"[;\n]", script):
315
+ # Split respecting brace nesting
316
+ for cmd_str in self._split_script(script):
263
317
  cmd_str = cmd_str.strip()
264
318
  if not cmd_str:
265
319
  continue
266
320
 
267
- cmd = self._parse_command(cmd_str, extended_regex)
268
- if cmd:
269
- commands.append(cmd)
321
+ # Handle grouped commands: addr{cmd1;cmd2}
322
+ brace_pos = cmd_str.find("{")
323
+ if brace_pos != -1 and cmd_str.endswith("}"):
324
+ addr_prefix = cmd_str[:brace_pos]
325
+ inner = cmd_str[brace_pos + 1 : -1]
326
+ for inner_cmd in inner.split(";"):
327
+ inner_cmd = inner_cmd.strip()
328
+ if inner_cmd:
329
+ full_cmd = addr_prefix + inner_cmd
330
+ cmd = self._parse_command(full_cmd, extended_regex)
331
+ if cmd:
332
+ commands.append(cmd)
333
+ else:
334
+ cmd = self._parse_command(cmd_str, extended_regex)
335
+ if cmd:
336
+ commands.append(cmd)
270
337
 
271
338
  return commands
272
339
 
340
+ def _split_script(self, script: str) -> list[str]:
341
+ """Split a script on semicolons and newlines, respecting brace nesting."""
342
+ parts: list[str] = []
343
+ current: list[str] = []
344
+ depth = 0
345
+
346
+ for ch in script:
347
+ if ch == "{":
348
+ depth += 1
349
+ current.append(ch)
350
+ elif ch == "}":
351
+ depth -= 1
352
+ current.append(ch)
353
+ elif ch in ";\n" and depth == 0:
354
+ parts.append("".join(current))
355
+ current = []
356
+ else:
357
+ current.append(ch)
358
+
359
+ if current:
360
+ parts.append("".join(current))
361
+
362
+ return parts
363
+
273
364
  def _parse_command(self, cmd_str: str, extended_regex: bool) -> SedCommand_ | None:
274
365
  """Parse a single sed command."""
275
366
  pos = 0
@@ -299,7 +390,8 @@ class SedCommand:
299
390
  pattern = cmd_str[1:end]
300
391
  flags = re.IGNORECASE if extended_regex else 0
301
392
  try:
302
- address = SedAddress(type="regex", regex=re.compile(pattern, flags))
393
+ py_pat = pattern if extended_regex else _bre_to_python(pattern)
394
+ address = SedAddress(type="regex", regex=re.compile(py_pat, flags))
303
395
  except re.error as e:
304
396
  raise ValueError(f"invalid regex: {e}")
305
397
  pos = end + 1
@@ -320,11 +412,12 @@ class SedCommand:
320
412
  end2 = self._find_delimiter(cmd_str, pos + 1, "/")
321
413
  if end2 != -1:
322
414
  pattern2 = cmd_str[pos + 1:end2]
415
+ py_pat2 = pattern2 if extended_regex else _bre_to_python(pattern2)
323
416
  try:
324
417
  address = SedAddress(
325
418
  type="range",
326
419
  regex=address.regex,
327
- end_regex=re.compile(pattern2, flags),
420
+ end_regex=re.compile(py_pat2, flags),
328
421
  )
329
422
  except re.error as e:
330
423
  raise ValueError(f"invalid regex: {e}")
@@ -334,11 +427,21 @@ class SedCommand:
334
427
  while pos < len(cmd_str) and cmd_str[pos] in " \t":
335
428
  pos += 1
336
429
 
430
+ # Check for negation
431
+ negate = False
432
+ if pos < len(cmd_str) and cmd_str[pos] == "!":
433
+ negate = True
434
+ pos += 1
435
+ # Skip whitespace after !
436
+ while pos < len(cmd_str) and cmd_str[pos] in " \t":
437
+ pos += 1
438
+
337
439
  if pos >= len(cmd_str):
338
440
  return None
339
441
 
340
442
  cmd_char = cmd_str[pos]
341
443
  pos += 1
444
+ result = None
342
445
 
343
446
  if cmd_char == "s":
344
447
  # Substitution
@@ -370,11 +473,12 @@ class SedCommand:
370
473
  regex_flags |= re.IGNORECASE
371
474
 
372
475
  try:
373
- compiled = re.compile(pattern, regex_flags)
476
+ py_pat = pattern if extended_regex else _bre_to_python(pattern)
477
+ compiled = re.compile(py_pat, regex_flags)
374
478
  except re.error as e:
375
479
  raise ValueError(f"invalid regex: {e}")
376
480
 
377
- return SedCommand_(
481
+ result = SedCommand_(
378
482
  cmd="s",
379
483
  address=address,
380
484
  pattern=compiled,
@@ -404,7 +508,7 @@ class SedCommand:
404
508
  if len(source) != len(dest):
405
509
  raise ValueError("y command requires equal length strings")
406
510
 
407
- return SedCommand_(
511
+ result = SedCommand_(
408
512
  cmd="y",
409
513
  address=address,
410
514
  source=source,
@@ -412,91 +516,95 @@ class SedCommand:
412
516
  )
413
517
 
414
518
  elif cmd_char == "d":
415
- return SedCommand_(cmd="d", address=address)
519
+ result = SedCommand_(cmd="d", address=address)
416
520
 
417
521
  elif cmd_char == "=":
418
522
  # Print line number
419
- return SedCommand_(cmd="=", address=address)
523
+ result = SedCommand_(cmd="=", address=address)
420
524
 
421
525
  elif cmd_char == "p":
422
- return SedCommand_(cmd="p", address=address)
526
+ result = SedCommand_(cmd="p", address=address)
423
527
 
424
528
  elif cmd_char == "l":
425
529
  # List pattern space with escapes
426
- return SedCommand_(cmd="l", address=address)
530
+ result = SedCommand_(cmd="l", address=address)
427
531
 
428
532
  elif cmd_char == "F":
429
533
  # Print filename
430
- return SedCommand_(cmd="F", address=address)
534
+ result = SedCommand_(cmd="F", address=address)
431
535
 
432
536
  elif cmd_char == "q":
433
- return SedCommand_(cmd="q", address=address)
537
+ result = SedCommand_(cmd="q", address=address)
434
538
 
435
539
  elif cmd_char in ("a", "i", "c"):
436
540
  # Append, insert, or change
437
541
  text = cmd_str[pos:].lstrip()
438
542
  if text.startswith("\\"):
439
543
  text = text[1:].lstrip()
440
- return SedCommand_(cmd=cmd_char, address=address, text=text)
544
+ result = SedCommand_(cmd=cmd_char, address=address, text=text)
441
545
 
442
546
  elif cmd_char in ("h", "H", "g", "G", "x"):
443
547
  # Hold space commands
444
- return SedCommand_(cmd=cmd_char, address=address)
548
+ result = SedCommand_(cmd=cmd_char, address=address)
445
549
 
446
550
  elif cmd_char in ("n", "N"):
447
551
  # Next line commands
448
- return SedCommand_(cmd=cmd_char, address=address)
552
+ result = SedCommand_(cmd=cmd_char, address=address)
449
553
 
450
554
  elif cmd_char in ("P", "D"):
451
555
  # Print/delete first line of pattern space
452
- return SedCommand_(cmd=cmd_char, address=address)
556
+ result = SedCommand_(cmd=cmd_char, address=address)
453
557
 
454
558
  elif cmd_char == "b":
455
559
  # Branch to label
456
560
  label = cmd_str[pos:].strip()
457
- return SedCommand_(cmd="b", address=address, label=label)
561
+ result = SedCommand_(cmd="b", address=address, label=label)
458
562
 
459
563
  elif cmd_char == "t":
460
564
  # Branch on successful substitute
461
565
  label = cmd_str[pos:].strip()
462
- return SedCommand_(cmd="t", address=address, label=label)
566
+ result = SedCommand_(cmd="t", address=address, label=label)
463
567
 
464
568
  elif cmd_char == "T":
465
569
  # Branch on failed substitute
466
570
  label = cmd_str[pos:].strip()
467
- return SedCommand_(cmd="T", address=address, label=label)
571
+ result = SedCommand_(cmd="T", address=address, label=label)
468
572
 
469
573
  elif cmd_char == ":":
470
574
  # Label definition
471
575
  label = cmd_str[pos:].strip()
472
- return SedCommand_(cmd=":", label=label)
576
+ result = SedCommand_(cmd=":", label=label)
473
577
 
474
578
  elif cmd_char == "r":
475
579
  # Read file
476
580
  filename = cmd_str[pos:].strip()
477
- return SedCommand_(cmd="r", address=address, filename=filename)
581
+ result = SedCommand_(cmd="r", address=address, filename=filename)
478
582
 
479
583
  elif cmd_char == "w":
480
584
  # Write to file
481
585
  filename = cmd_str[pos:].strip()
482
- return SedCommand_(cmd="w", address=address, filename=filename)
586
+ result = SedCommand_(cmd="w", address=address, filename=filename)
483
587
 
484
588
  elif cmd_char == "R":
485
589
  # Read single line from file
486
590
  filename = cmd_str[pos:].strip()
487
- return SedCommand_(cmd="R", address=address, filename=filename)
591
+ result = SedCommand_(cmd="R", address=address, filename=filename)
488
592
 
489
593
  elif cmd_char == "{":
490
594
  # Start of block (handled in parsing)
491
- return None
595
+ pass
492
596
 
493
597
  elif cmd_char == "}":
494
598
  # End of block (handled in parsing)
495
- return None
599
+ pass
496
600
 
497
601
  else:
498
602
  raise ValueError(f"unknown command: {cmd_char}")
499
603
 
604
+ if result is not None and negate:
605
+ result.negate = True
606
+ return result
607
+
500
608
  def _find_delimiter(self, s: str, start: int, delim: str) -> int:
501
609
  """Find the next unescaped delimiter."""
502
610
  i = start
@@ -570,8 +678,11 @@ class SedCommand:
570
678
  cmd_idx += 1
571
679
  continue
572
680
 
573
- # Check if address matches
574
- if not self._address_matches(cmd.address, line_num, total_lines, pattern_space, in_range, cmd_idx):
681
+ # Check if address matches (with negation support)
682
+ matches = self._address_matches(cmd.address, line_num, total_lines, pattern_space, in_range, cmd_idx)
683
+ if cmd.negate:
684
+ matches = not matches
685
+ if not matches:
575
686
  cmd_idx += 1
576
687
  continue
577
688
 
@@ -113,6 +113,15 @@ class StatCommand:
113
113
  # %G - group name (hardcoded for virtual FS)
114
114
  result = result.replace("%G", "group")
115
115
 
116
+ # %Y - modification time as seconds since Epoch
117
+ result = result.replace("%Y", str(int(stat.mtime)))
118
+
119
+ # %X - access time as seconds since Epoch (same as mtime for our FS)
120
+ result = result.replace("%X", str(int(stat.mtime)))
121
+
122
+ # %Z - change time as seconds since Epoch (same as mtime for our FS)
123
+ result = result.replace("%Z", str(int(stat.mtime)))
124
+
116
125
  return result + "\n"
117
126
 
118
127
  def _default_format(self, path: str, stat) -> str:
@@ -0,0 +1,5 @@
1
+ """Time command."""
2
+
3
+ from .time import TimeCommand
4
+
5
+ __all__ = ["TimeCommand"]
@@ -0,0 +1,74 @@
1
+ """Time command implementation.
2
+
3
+ Usage: time [-p] [COMMAND [ARGS]...]
4
+
5
+ Run COMMAND and print timing statistics.
6
+
7
+ Options:
8
+ -p Use POSIX output format
9
+ """
10
+
11
+ import time as time_module
12
+ from ...types import CommandContext, ExecResult
13
+
14
+
15
+ class TimeCommand:
16
+ """The time command."""
17
+
18
+ name = "time"
19
+
20
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
21
+ """Execute the time command."""
22
+ posix_format = False
23
+ command_args: list[str] = []
24
+
25
+ # Parse arguments
26
+ i = 0
27
+ while i < len(args):
28
+ arg = args[i]
29
+ if arg == "-p" and not command_args:
30
+ posix_format = True
31
+ elif arg == "--":
32
+ command_args.extend(args[i + 1:])
33
+ break
34
+ else:
35
+ command_args.extend(args[i:])
36
+ break
37
+ i += 1
38
+
39
+ # If no command, just show timing for empty command
40
+ if not command_args:
41
+ if posix_format:
42
+ timing = "real 0.00\nuser 0.00\nsys 0.00\n"
43
+ else:
44
+ timing = "\nreal\t0m0.000s\nuser\t0m0.000s\nsys\t0m0.000s\n"
45
+ return ExecResult(stdout="", stderr=timing, exit_code=0)
46
+
47
+ # Execute the command and measure time
48
+ if ctx.exec is None:
49
+ return ExecResult(
50
+ stdout="",
51
+ stderr="time: cannot execute subcommand\n",
52
+ exit_code=1,
53
+ )
54
+
55
+ # Build command string
56
+ command_str = " ".join(command_args)
57
+
58
+ start_time = time_module.time()
59
+ result = await ctx.exec(command_str, {"cwd": ctx.cwd})
60
+ elapsed = time_module.time() - start_time
61
+
62
+ # Format timing output
63
+ if posix_format:
64
+ timing = f"real {elapsed:.2f}\nuser 0.00\nsys 0.00\n"
65
+ else:
66
+ minutes = int(elapsed // 60)
67
+ seconds = elapsed % 60
68
+ timing = f"\nreal\t{minutes}m{seconds:.3f}s\nuser\t0m0.000s\nsys\t0m0.000s\n"
69
+
70
+ return ExecResult(
71
+ stdout=result.stdout,
72
+ stderr=result.stderr + timing,
73
+ exit_code=result.exit_code,
74
+ )
@@ -7,11 +7,56 @@ A FILE argument that does not exist is created empty.
7
7
 
8
8
  Options:
9
9
  -c, --no-create do not create any files
10
+ -d, --date=DATE parse DATE and use it instead of current time
10
11
  """
11
12
 
13
+ import time
14
+ import re
12
15
  from ...types import CommandContext, ExecResult
13
16
 
14
17
 
18
+ def parse_date(date_str: str) -> float | None:
19
+ """Parse a date string and return timestamp.
20
+
21
+ Supports:
22
+ - YYYY-MM-DD
23
+ - YYYY/MM/DD
24
+ - YYYY-MM-DD HH:MM:SS
25
+ - ISO 8601 variations
26
+ """
27
+ date_str = date_str.strip().strip("'\"")
28
+
29
+ # Try various date formats
30
+ patterns = [
31
+ # YYYY-MM-DD HH:MM:SS
32
+ (r"^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$",
33
+ lambda m: (int(m[1]), int(m[2]), int(m[3]), int(m[4]), int(m[5]), int(m[6]))),
34
+ # YYYY-MM-DD
35
+ (r"^(\d{4})-(\d{2})-(\d{2})$",
36
+ lambda m: (int(m[1]), int(m[2]), int(m[3]), 0, 0, 0)),
37
+ # YYYY/MM/DD
38
+ (r"^(\d{4})/(\d{2})/(\d{2})$",
39
+ lambda m: (int(m[1]), int(m[2]), int(m[3]), 0, 0, 0)),
40
+ # YYYY/MM/DD HH:MM:SS
41
+ (r"^(\d{4})/(\d{2})/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$",
42
+ lambda m: (int(m[1]), int(m[2]), int(m[3]), int(m[4]), int(m[5]), int(m[6]))),
43
+ ]
44
+
45
+ for pattern, extractor in patterns:
46
+ match = re.match(pattern, date_str)
47
+ if match:
48
+ year, month, day, hour, minute, second = extractor(match)
49
+ try:
50
+ import calendar
51
+ # Create a struct_time and convert to timestamp
52
+ t = (year, month, day, hour, minute, second, 0, 0, -1)
53
+ return calendar.timegm(t)
54
+ except (ValueError, OverflowError):
55
+ return None
56
+
57
+ return None
58
+
59
+
15
60
  class TouchCommand:
16
61
  """The touch command."""
17
62
 
@@ -20,6 +65,7 @@ class TouchCommand:
20
65
  async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
21
66
  """Execute the touch command."""
22
67
  no_create = False
68
+ date_time: float | None = None
23
69
  files: list[str] = []
24
70
 
25
71
  # Parse arguments
@@ -32,6 +78,32 @@ class TouchCommand:
32
78
  elif arg.startswith("--"):
33
79
  if arg == "--no-create":
34
80
  no_create = True
81
+ elif arg.startswith("--date="):
82
+ date_str = arg[7:]
83
+ date_time = parse_date(date_str)
84
+ if date_time is None:
85
+ return ExecResult(
86
+ stdout="",
87
+ stderr=f"touch: invalid date '{date_str}'\n",
88
+ exit_code=1,
89
+ )
90
+ elif arg == "--date":
91
+ # Next arg is the date
92
+ if i + 1 >= len(args):
93
+ return ExecResult(
94
+ stdout="",
95
+ stderr="touch: option '--date' requires an argument\n",
96
+ exit_code=1,
97
+ )
98
+ i += 1
99
+ date_str = args[i]
100
+ date_time = parse_date(date_str)
101
+ if date_time is None:
102
+ return ExecResult(
103
+ stdout="",
104
+ stderr=f"touch: invalid date '{date_str}'\n",
105
+ exit_code=1,
106
+ )
35
107
  else:
36
108
  return ExecResult(
37
109
  stdout="",
@@ -39,15 +111,49 @@ class TouchCommand:
39
111
  exit_code=1,
40
112
  )
41
113
  elif arg.startswith("-") and arg != "-":
42
- for c in arg[1:]:
114
+ j = 1
115
+ while j < len(arg):
116
+ c = arg[j]
43
117
  if c == "c":
44
118
  no_create = True
119
+ elif c == "d":
120
+ # -d DATE: next part or next arg is the date
121
+ if j + 1 < len(arg):
122
+ # Date is rest of this arg
123
+ date_str = arg[j + 1:]
124
+ date_time = parse_date(date_str)
125
+ if date_time is None:
126
+ return ExecResult(
127
+ stdout="",
128
+ stderr=f"touch: invalid date '{date_str}'\n",
129
+ exit_code=1,
130
+ )
131
+ break
132
+ elif i + 1 < len(args):
133
+ # Date is next arg
134
+ i += 1
135
+ date_str = args[i]
136
+ date_time = parse_date(date_str)
137
+ if date_time is None:
138
+ return ExecResult(
139
+ stdout="",
140
+ stderr=f"touch: invalid date '{date_str}'\n",
141
+ exit_code=1,
142
+ )
143
+ break
144
+ else:
145
+ return ExecResult(
146
+ stdout="",
147
+ stderr="touch: option requires an argument -- 'd'\n",
148
+ exit_code=1,
149
+ )
45
150
  else:
46
151
  return ExecResult(
47
152
  stdout="",
48
153
  stderr=f"touch: invalid option -- '{c}'\n",
49
154
  exit_code=1,
50
155
  )
156
+ j += 1
51
157
  else:
52
158
  files.append(arg)
53
159
  i += 1
@@ -62,6 +168,10 @@ class TouchCommand:
62
168
  stderr = ""
63
169
  exit_code = 0
64
170
 
171
+ # Use current time if no date specified
172
+ if date_time is None:
173
+ date_time = time.time()
174
+
65
175
  for f in files:
66
176
  try:
67
177
  path = ctx.fs.resolve_path(ctx.cwd, f)
@@ -69,23 +179,23 @@ class TouchCommand:
69
179
  try:
70
180
  stat = await ctx.fs.stat(path)
71
181
  if stat.is_directory:
72
- # Touching a directory - we can't easily update dir mtime
73
- # in current implementation, so just continue
182
+ # Update directory timestamp if possible
183
+ await ctx.fs.utimes(path, date_time, date_time)
74
184
  continue
75
- # File exists - read and re-write to update timestamp
76
- content = await ctx.fs.read_file(path)
77
- await ctx.fs.write_file(path, content)
185
+ # File exists - use utimes to update timestamp
186
+ await ctx.fs.utimes(path, date_time, date_time)
78
187
  except FileNotFoundError:
79
188
  # File doesn't exist
80
189
  if no_create:
81
190
  continue
82
- # Create empty file
191
+ # Create empty file and set its time
83
192
  await ctx.fs.write_file(path, "")
193
+ await ctx.fs.utimes(path, date_time, date_time)
84
194
  except FileNotFoundError:
85
195
  stderr += f"touch: cannot touch '{f}': No such file or directory\n"
86
196
  exit_code = 1
87
197
  except IsADirectoryError:
88
- # Touching a directory is fine, just update timestamp (no-op)
198
+ # Touching a directory is fine
89
199
  pass
90
200
 
91
201
  return ExecResult(stdout="", stderr=stderr, exit_code=exit_code)
@@ -0,0 +1,5 @@
1
+ """Whoami command."""
2
+
3
+ from .whoami import WhoamiCommand
4
+
5
+ __all__ = ["WhoamiCommand"]
@@ -0,0 +1,18 @@
1
+ """Whoami command implementation.
2
+
3
+ Usage: whoami
4
+
5
+ Print the effective username.
6
+ """
7
+
8
+ from ...types import CommandContext, ExecResult
9
+
10
+
11
+ class WhoamiCommand:
12
+ """The whoami command."""
13
+
14
+ name = "whoami"
15
+
16
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
17
+ """Execute the whoami command."""
18
+ return ExecResult(stdout="user\n", stderr="", exit_code=0)
@@ -609,6 +609,28 @@ class InMemoryFs:
609
609
 
610
610
  return entry.target
611
611
 
612
+ async def utimes(self, path: str, atime: float, mtime: float) -> None:
613
+ """Set access and modification times for a file."""
614
+ resolved = self._resolve_path_with_symlinks(path)
615
+ entry = self._data.get(resolved)
616
+ if entry is None:
617
+ raise FileNotFoundError(f"No such file or directory: {path}")
618
+ # Update mtime (we only track mtime, atime is ignored)
619
+ if entry.type == "file":
620
+ self._data[resolved] = FileEntry(
621
+ content=entry.content, mode=entry.mode, mtime=mtime
622
+ )
623
+ elif entry.type == "directory":
624
+ self._data[resolved] = DirectoryEntry(mode=entry.mode, mtime=mtime)
625
+ elif entry.type == "symlink":
626
+ self._data[resolved] = SymlinkEntry(
627
+ target=entry.target, mode=entry.mode, mtime=mtime
628
+ )
629
+
630
+ async def realpath(self, path: str) -> str:
631
+ """Resolve path to absolute canonical path (resolve all symlinks)."""
632
+ return self._resolve_path_with_symlinks(path)
633
+
612
634
  def resolve_path(self, base: str, path: str) -> str:
613
635
  """Resolve a path relative to a base."""
614
636
  if path.startswith("/"):