just-bash 0.1.5__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 (49) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +362 -17
  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 +141 -3
  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 +32 -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/shuf/__init__.py +5 -0
  17. just_bash/commands/shuf/shuf.py +242 -0
  18. just_bash/commands/stat/stat.py +9 -0
  19. just_bash/commands/time/__init__.py +5 -0
  20. just_bash/commands/time/time.py +74 -0
  21. just_bash/commands/touch/touch.py +118 -8
  22. just_bash/commands/whoami/__init__.py +5 -0
  23. just_bash/commands/whoami/whoami.py +18 -0
  24. just_bash/fs/in_memory_fs.py +22 -0
  25. just_bash/fs/overlay_fs.py +22 -1
  26. just_bash/interpreter/__init__.py +1 -1
  27. just_bash/interpreter/builtins/__init__.py +2 -0
  28. just_bash/interpreter/builtins/control.py +4 -8
  29. just_bash/interpreter/builtins/declare.py +321 -24
  30. just_bash/interpreter/builtins/getopts.py +163 -0
  31. just_bash/interpreter/builtins/let.py +2 -2
  32. just_bash/interpreter/builtins/local.py +71 -5
  33. just_bash/interpreter/builtins/misc.py +22 -6
  34. just_bash/interpreter/builtins/readonly.py +38 -10
  35. just_bash/interpreter/builtins/set.py +58 -8
  36. just_bash/interpreter/builtins/test.py +136 -19
  37. just_bash/interpreter/builtins/unset.py +62 -10
  38. just_bash/interpreter/conditionals.py +29 -4
  39. just_bash/interpreter/control_flow.py +61 -17
  40. just_bash/interpreter/expansion.py +1647 -104
  41. just_bash/interpreter/interpreter.py +436 -69
  42. just_bash/interpreter/types.py +263 -2
  43. just_bash/parser/__init__.py +2 -0
  44. just_bash/parser/lexer.py +295 -26
  45. just_bash/parser/parser.py +523 -64
  46. just_bash/types.py +11 -0
  47. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  48. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
  49. {just_bash-0.1.5.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
 
@@ -0,0 +1,5 @@
1
+ """Shuf command."""
2
+
3
+ from .shuf import ShufCommand
4
+
5
+ __all__ = ["ShufCommand"]
@@ -0,0 +1,242 @@
1
+ """Shuf command implementation.
2
+
3
+ Usage: shuf [OPTION]... [FILE]
4
+ or: shuf -e [OPTION]... [ARG]...
5
+ or: shuf -i LO-HI [OPTION]...
6
+
7
+ Write a random permutation of the input lines to standard output.
8
+
9
+ Options:
10
+ -e, --echo treat each ARG as an input line
11
+ -i, --input-range=LO-HI treat each number LO through HI as an input line
12
+ -n, --head-count=COUNT output at most COUNT lines
13
+ -o, --output=FILE write result to FILE instead of standard output
14
+ -r, --repeat output lines can be repeated (requires -n)
15
+ --random-source=FILE get random bytes from FILE
16
+ """
17
+
18
+ import random
19
+ from ...types import CommandContext, ExecResult
20
+
21
+
22
+ class ShufCommand:
23
+ """The shuf command."""
24
+
25
+ name = "shuf"
26
+
27
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
28
+ """Execute the shuf command."""
29
+ # Parse arguments
30
+ echo_mode = False
31
+ input_range = None
32
+ head_count = None
33
+ output_file = None
34
+ repeat = False
35
+ random_source = None
36
+ input_args: list[str] = []
37
+ input_file = None
38
+
39
+ i = 0
40
+ while i < len(args):
41
+ arg = args[i]
42
+
43
+ if arg in ("-e", "--echo"):
44
+ echo_mode = True
45
+ elif arg in ("-r", "--repeat"):
46
+ repeat = True
47
+ elif arg == "-n":
48
+ if i + 1 >= len(args):
49
+ return ExecResult(
50
+ stdout="",
51
+ stderr="shuf: option requires an argument -- 'n'\n",
52
+ exit_code=1,
53
+ )
54
+ i += 1
55
+ try:
56
+ head_count = int(args[i])
57
+ if head_count < 0:
58
+ raise ValueError()
59
+ except ValueError:
60
+ return ExecResult(
61
+ stdout="",
62
+ stderr=f"shuf: invalid line count: '{args[i]}'\n",
63
+ exit_code=1,
64
+ )
65
+ elif arg.startswith("-n"):
66
+ try:
67
+ head_count = int(arg[2:])
68
+ if head_count < 0:
69
+ raise ValueError()
70
+ except ValueError:
71
+ return ExecResult(
72
+ stdout="",
73
+ stderr=f"shuf: invalid line count: '{arg[2:]}'\n",
74
+ exit_code=1,
75
+ )
76
+ elif arg.startswith("--head-count="):
77
+ try:
78
+ head_count = int(arg[13:])
79
+ if head_count < 0:
80
+ raise ValueError()
81
+ except ValueError:
82
+ return ExecResult(
83
+ stdout="",
84
+ stderr=f"shuf: invalid line count: '{arg[13:]}'\n",
85
+ exit_code=1,
86
+ )
87
+ elif arg == "-i":
88
+ if i + 1 >= len(args):
89
+ return ExecResult(
90
+ stdout="",
91
+ stderr="shuf: option requires an argument -- 'i'\n",
92
+ exit_code=1,
93
+ )
94
+ i += 1
95
+ input_range = args[i]
96
+ elif arg.startswith("-i"):
97
+ input_range = arg[2:]
98
+ elif arg.startswith("--input-range="):
99
+ input_range = arg[14:]
100
+ elif arg == "-o":
101
+ if i + 1 >= len(args):
102
+ return ExecResult(
103
+ stdout="",
104
+ stderr="shuf: option requires an argument -- 'o'\n",
105
+ exit_code=1,
106
+ )
107
+ i += 1
108
+ output_file = args[i]
109
+ elif arg.startswith("-o"):
110
+ output_file = arg[2:]
111
+ elif arg.startswith("--output="):
112
+ output_file = arg[9:]
113
+ elif arg.startswith("--random-source="):
114
+ random_source = arg[16:]
115
+ elif arg == "--random-source":
116
+ if i + 1 >= len(args):
117
+ return ExecResult(
118
+ stdout="",
119
+ stderr="shuf: option requires an argument -- 'random-source'\n",
120
+ exit_code=1,
121
+ )
122
+ i += 1
123
+ random_source = args[i]
124
+ elif arg.startswith("-") and len(arg) > 1 and arg != "-":
125
+ return ExecResult(
126
+ stdout="",
127
+ stderr=f"shuf: invalid option -- '{arg[1]}'\n",
128
+ exit_code=1,
129
+ )
130
+ else:
131
+ if echo_mode:
132
+ input_args.append(arg)
133
+ elif input_file is None:
134
+ input_file = arg
135
+ else:
136
+ input_args.append(arg)
137
+
138
+ i += 1
139
+
140
+ # Set up random generator
141
+ rng = random.Random()
142
+ if random_source:
143
+ try:
144
+ path = ctx.fs.resolve_path(ctx.cwd, random_source)
145
+ seed_data = await ctx.fs.read_file(path)
146
+ # Use hash of file content as seed
147
+ rng.seed(hash(seed_data))
148
+ except FileNotFoundError:
149
+ return ExecResult(
150
+ stdout="",
151
+ stderr=f"shuf: {random_source}: No such file or directory\n",
152
+ exit_code=1,
153
+ )
154
+
155
+ # Get input lines
156
+ lines: list[str] = []
157
+
158
+ if input_range:
159
+ # Parse range LO-HI
160
+ if "-" not in input_range:
161
+ return ExecResult(
162
+ stdout="",
163
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
164
+ exit_code=1,
165
+ )
166
+ parts = input_range.split("-", 1)
167
+ try:
168
+ lo = int(parts[0])
169
+ hi = int(parts[1])
170
+ except ValueError:
171
+ return ExecResult(
172
+ stdout="",
173
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
174
+ exit_code=1,
175
+ )
176
+ if lo > hi:
177
+ return ExecResult(
178
+ stdout="",
179
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
180
+ exit_code=1,
181
+ )
182
+ lines = [str(n) for n in range(lo, hi + 1)]
183
+ elif echo_mode:
184
+ lines = input_args
185
+ else:
186
+ # Read from file or stdin
187
+ if input_file:
188
+ try:
189
+ path = ctx.fs.resolve_path(ctx.cwd, input_file)
190
+ content = await ctx.fs.read_file(path)
191
+ except FileNotFoundError:
192
+ return ExecResult(
193
+ stdout="",
194
+ stderr=f"shuf: {input_file}: No such file or directory\n",
195
+ exit_code=1,
196
+ )
197
+ else:
198
+ content = ctx.stdin
199
+
200
+ if content:
201
+ # Split into lines, preserving empty lines but removing final newline
202
+ if content.endswith("\n"):
203
+ content = content[:-1]
204
+ if content:
205
+ lines = content.split("\n")
206
+
207
+ # Handle empty input
208
+ if not lines:
209
+ if output_file:
210
+ path = ctx.fs.resolve_path(ctx.cwd, output_file)
211
+ await ctx.fs.write_file(path, "")
212
+ return ExecResult(stdout="", stderr="", exit_code=0)
213
+
214
+ # Generate output
215
+ output_lines: list[str] = []
216
+
217
+ if repeat:
218
+ # With repeat, we can output more lines than input
219
+ count = head_count if head_count is not None else len(lines)
220
+ for _ in range(count):
221
+ output_lines.append(rng.choice(lines))
222
+ else:
223
+ # Shuffle and optionally limit
224
+ shuffled = lines.copy()
225
+ rng.shuffle(shuffled)
226
+ if head_count is not None:
227
+ output_lines = shuffled[:head_count]
228
+ else:
229
+ output_lines = shuffled
230
+
231
+ # Build output
232
+ output = "\n".join(output_lines)
233
+ if output:
234
+ output += "\n"
235
+
236
+ # Write to file or stdout
237
+ if output_file:
238
+ path = ctx.fs.resolve_path(ctx.cwd, output_file)
239
+ await ctx.fs.write_file(path, output)
240
+ return ExecResult(stdout="", stderr="", exit_code=0)
241
+
242
+ return ExecResult(stdout=output, stderr="", exit_code=0)
@@ -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
+ )