just-bash 0.1.5__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 (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,801 @@
1
+ """Curl command implementation."""
2
+
3
+ import base64
4
+ import gzip
5
+ import time
6
+ import zlib
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+ from urllib.parse import urlencode, urlparse, quote
10
+
11
+ from ...types import CommandContext, ExecResult
12
+
13
+
14
+ @dataclass
15
+ class FormField:
16
+ """A form field for multipart data."""
17
+ name: str
18
+ value: str
19
+ filename: Optional[str] = None
20
+ content_type: Optional[str] = None
21
+
22
+
23
+ @dataclass
24
+ class CurlOptions:
25
+ """Parsed curl options."""
26
+ method: str = "GET"
27
+ headers: dict[str, str] = field(default_factory=dict)
28
+ data: Optional[str] = None
29
+ data_binary: bool = False
30
+ form_fields: list[FormField] = field(default_factory=list)
31
+ user: Optional[str] = None
32
+ upload_file: Optional[str] = None
33
+ cookie_jar: Optional[str] = None
34
+ cookie_file: Optional[str] = None # -b @file
35
+ output_file: Optional[str] = None
36
+ use_remote_name: bool = False
37
+ head_only: bool = False
38
+ include_headers: bool = False
39
+ silent: bool = False
40
+ show_error: bool = False
41
+ fail_silently: bool = False
42
+ follow_redirects: bool = True
43
+ max_redirects: Optional[int] = None # --max-redirs
44
+ compressed: bool = False # --compressed
45
+ write_out: Optional[str] = None
46
+ verbose: bool = False
47
+ timeout_ms: Optional[int] = None
48
+ url: Optional[str] = None
49
+
50
+
51
+ def encode_form_data(input_str: str) -> str:
52
+ """URL-encode form data in curl's --data-urlencode format."""
53
+ eq_index = input_str.find("=")
54
+ if eq_index >= 0:
55
+ name = input_str[:eq_index]
56
+ value = input_str[eq_index + 1:]
57
+ if name:
58
+ return f"{quote(name, safe='')}={quote(value, safe='')}"
59
+ return quote(value, safe='')
60
+ return quote(input_str, safe='')
61
+
62
+
63
+ def parse_form_field(spec: str) -> Optional[FormField]:
64
+ """Parse -F/--form field specification."""
65
+ eq_index = spec.find("=")
66
+ if eq_index < 0:
67
+ return None
68
+
69
+ name = spec[:eq_index]
70
+ value = spec[eq_index + 1:]
71
+ filename = None
72
+ content_type = None
73
+
74
+ # Check for ;type= suffix
75
+ if ";type=" in value:
76
+ type_idx = value.rfind(";type=")
77
+ content_type = value[type_idx + 6:]
78
+ value = value[:type_idx]
79
+
80
+ # Check for ;filename= suffix
81
+ if ";filename=" in value:
82
+ fn_idx = value.find(";filename=")
83
+ fn_end = value.find(";", fn_idx + 1)
84
+ if fn_end < 0:
85
+ fn_end = len(value)
86
+ filename = value[fn_idx + 10:fn_end]
87
+ value = value[:fn_idx] + (value[fn_end:] if fn_end < len(value) else "")
88
+
89
+ # @ means file upload, < means file content
90
+ if value.startswith("@") or value.startswith("<"):
91
+ if not filename:
92
+ filename = value[1:].split("/")[-1]
93
+
94
+ return FormField(name=name, value=value, filename=filename, content_type=content_type)
95
+
96
+
97
+ def generate_multipart_body(
98
+ fields: list[FormField],
99
+ file_contents: dict[str, str],
100
+ ) -> tuple[str, str]:
101
+ """Generate multipart form data body and boundary."""
102
+ boundary = f"----CurlFormBoundary{int(time.time() * 1000):x}"
103
+ parts = []
104
+
105
+ for field in fields:
106
+ value = field.value
107
+
108
+ # Replace file references with content
109
+ if value.startswith("@") or value.startswith("<"):
110
+ file_path = value[1:]
111
+ value = file_contents.get(file_path, "")
112
+
113
+ part = f"--{boundary}\r\n"
114
+ if field.filename:
115
+ part += f'Content-Disposition: form-data; name="{field.name}"; filename="{field.filename}"\r\n'
116
+ if field.content_type:
117
+ part += f"Content-Type: {field.content_type}\r\n"
118
+ else:
119
+ part += f'Content-Disposition: form-data; name="{field.name}"\r\n'
120
+ part += f"\r\n{value}\r\n"
121
+ parts.append(part)
122
+
123
+ parts.append(f"--{boundary}--\r\n")
124
+ return "".join(parts), boundary
125
+
126
+
127
+ def format_headers(headers: dict[str, str]) -> str:
128
+ """Format response headers for output."""
129
+ return "\r\n".join(f"{name}: {value}" for name, value in headers.items())
130
+
131
+
132
+ def extract_filename(url: str) -> str:
133
+ """Extract filename from URL for -O option."""
134
+ try:
135
+ parsed = urlparse(url)
136
+ pathname = parsed.path
137
+ filename = pathname.split("/")[-1] if pathname else ""
138
+ return filename or "index.html"
139
+ except Exception:
140
+ return "index.html"
141
+
142
+
143
+ def apply_write_out(format_str: str, result: dict) -> str:
144
+ """Apply write-out format string replacements."""
145
+ output = format_str
146
+ status = str(result.get("status", 0))
147
+ headers = result.get("headers", {})
148
+
149
+ # Basic variables
150
+ output = output.replace("%{http_code}", status)
151
+ output = output.replace("%{response_code}", status) # Alias for http_code
152
+ output = output.replace("%{content_type}", headers.get("content-type", ""))
153
+ output = output.replace("%{url_effective}", result.get("url", ""))
154
+ output = output.replace("%{size_download}", str(result.get("body_length", 0)))
155
+
156
+ # Redirect variables
157
+ output = output.replace("%{num_redirects}", str(result.get("redirect_count", 0)))
158
+ output = output.replace("%{redirect_url}", result.get("url", ""))
159
+
160
+ # Header size (calculated from formatted headers)
161
+ header_size = result.get("header_size", 0)
162
+ output = output.replace("%{header_size}", str(header_size))
163
+
164
+ # Timing variables
165
+ time_total = result.get("time_total", 0.0)
166
+ output = output.replace("%{time_total}", f"{time_total:.6f}")
167
+
168
+ # Speed (bytes/sec)
169
+ speed_download = result.get("speed_download", 0.0)
170
+ output = output.replace("%{speed_download}", f"{speed_download:.3f}")
171
+
172
+ output = output.replace("\\n", "\n")
173
+ return output
174
+
175
+
176
+ def parse_options(args: list[str]) -> CurlOptions | ExecResult:
177
+ """Parse curl command line arguments."""
178
+ options = CurlOptions()
179
+ i = 0
180
+
181
+ while i < len(args):
182
+ arg = args[i]
183
+
184
+ if arg == "-X" or arg == "--request":
185
+ i += 1
186
+ options.method = args[i] if i < len(args) else "GET"
187
+ elif arg.startswith("-X"):
188
+ options.method = arg[2:]
189
+ elif arg.startswith("--request="):
190
+ options.method = arg[10:]
191
+
192
+ elif arg == "-H" or arg == "--header":
193
+ i += 1
194
+ if i < len(args):
195
+ header = args[i]
196
+ colon_idx = header.find(":")
197
+ if colon_idx > 0:
198
+ name = header[:colon_idx].strip()
199
+ value = header[colon_idx + 1:].strip()
200
+ options.headers[name] = value
201
+ elif arg.startswith("--header="):
202
+ header = arg[9:]
203
+ colon_idx = header.find(":")
204
+ if colon_idx > 0:
205
+ name = header[:colon_idx].strip()
206
+ value = header[colon_idx + 1:].strip()
207
+ options.headers[name] = value
208
+
209
+ elif arg == "-d" or arg == "--data" or arg == "--data-raw":
210
+ i += 1
211
+ options.data = args[i] if i < len(args) else ""
212
+ if options.method == "GET":
213
+ options.method = "POST"
214
+ elif arg.startswith("-d"):
215
+ options.data = arg[2:]
216
+ if options.method == "GET":
217
+ options.method = "POST"
218
+ elif arg.startswith("--data="):
219
+ options.data = arg[7:]
220
+ if options.method == "GET":
221
+ options.method = "POST"
222
+ elif arg.startswith("--data-raw="):
223
+ options.data = arg[11:]
224
+ if options.method == "GET":
225
+ options.method = "POST"
226
+
227
+ elif arg == "--data-binary":
228
+ i += 1
229
+ options.data = args[i] if i < len(args) else ""
230
+ options.data_binary = True
231
+ if options.method == "GET":
232
+ options.method = "POST"
233
+ elif arg.startswith("--data-binary="):
234
+ options.data = arg[14:]
235
+ options.data_binary = True
236
+ if options.method == "GET":
237
+ options.method = "POST"
238
+
239
+ elif arg == "--data-urlencode":
240
+ i += 1
241
+ value = args[i] if i < len(args) else ""
242
+ encoded = encode_form_data(value)
243
+ if options.data:
244
+ options.data = f"{options.data}&{encoded}"
245
+ else:
246
+ options.data = encoded
247
+ if options.method == "GET":
248
+ options.method = "POST"
249
+ elif arg.startswith("--data-urlencode="):
250
+ value = arg[17:]
251
+ encoded = encode_form_data(value)
252
+ if options.data:
253
+ options.data = f"{options.data}&{encoded}"
254
+ else:
255
+ options.data = encoded
256
+ if options.method == "GET":
257
+ options.method = "POST"
258
+
259
+ elif arg == "-F" or arg == "--form":
260
+ i += 1
261
+ if i < len(args):
262
+ form_field = parse_form_field(args[i])
263
+ if form_field:
264
+ options.form_fields.append(form_field)
265
+ if options.method == "GET":
266
+ options.method = "POST"
267
+ elif arg.startswith("--form="):
268
+ form_field = parse_form_field(arg[7:])
269
+ if form_field:
270
+ options.form_fields.append(form_field)
271
+ if options.method == "GET":
272
+ options.method = "POST"
273
+
274
+ elif arg == "-u" or arg == "--user":
275
+ i += 1
276
+ options.user = args[i] if i < len(args) else None
277
+ elif arg.startswith("-u"):
278
+ options.user = arg[2:]
279
+ elif arg.startswith("--user="):
280
+ options.user = arg[7:]
281
+
282
+ elif arg == "-A" or arg == "--user-agent":
283
+ i += 1
284
+ options.headers["User-Agent"] = args[i] if i < len(args) else ""
285
+ elif arg.startswith("-A"):
286
+ options.headers["User-Agent"] = arg[2:]
287
+ elif arg.startswith("--user-agent="):
288
+ options.headers["User-Agent"] = arg[13:]
289
+
290
+ elif arg == "-e" or arg == "--referer":
291
+ i += 1
292
+ options.headers["Referer"] = args[i] if i < len(args) else ""
293
+ elif arg.startswith("-e"):
294
+ options.headers["Referer"] = arg[2:]
295
+ elif arg.startswith("--referer="):
296
+ options.headers["Referer"] = arg[10:]
297
+
298
+ elif arg == "-b" or arg == "--cookie":
299
+ i += 1
300
+ cookie_value = args[i] if i < len(args) else ""
301
+ if cookie_value.startswith("@"):
302
+ options.cookie_file = cookie_value[1:] # Store file path without @
303
+ else:
304
+ options.headers["Cookie"] = cookie_value
305
+ elif arg.startswith("-b"):
306
+ cookie_value = arg[2:]
307
+ if cookie_value.startswith("@"):
308
+ options.cookie_file = cookie_value[1:]
309
+ else:
310
+ options.headers["Cookie"] = cookie_value
311
+ elif arg.startswith("--cookie="):
312
+ cookie_value = arg[9:]
313
+ if cookie_value.startswith("@"):
314
+ options.cookie_file = cookie_value[1:]
315
+ else:
316
+ options.headers["Cookie"] = cookie_value
317
+
318
+ elif arg == "-c" or arg == "--cookie-jar":
319
+ i += 1
320
+ options.cookie_jar = args[i] if i < len(args) else None
321
+ elif arg.startswith("--cookie-jar="):
322
+ options.cookie_jar = arg[13:]
323
+
324
+ elif arg == "-T" or arg == "--upload-file":
325
+ i += 1
326
+ options.upload_file = args[i] if i < len(args) else None
327
+ if options.method == "GET":
328
+ options.method = "PUT"
329
+ elif arg.startswith("--upload-file="):
330
+ options.upload_file = arg[14:]
331
+ if options.method == "GET":
332
+ options.method = "PUT"
333
+
334
+ elif arg == "-m" or arg == "--max-time":
335
+ i += 1
336
+ try:
337
+ secs = float(args[i] if i < len(args) else "0")
338
+ if secs > 0:
339
+ options.timeout_ms = int(secs * 1000)
340
+ except ValueError:
341
+ pass
342
+ elif arg.startswith("--max-time="):
343
+ try:
344
+ secs = float(arg[11:])
345
+ if secs > 0:
346
+ options.timeout_ms = int(secs * 1000)
347
+ except ValueError:
348
+ pass
349
+
350
+ elif arg == "--connect-timeout":
351
+ i += 1
352
+ try:
353
+ secs = float(args[i] if i < len(args) else "0")
354
+ if secs > 0 and options.timeout_ms is None:
355
+ options.timeout_ms = int(secs * 1000)
356
+ except ValueError:
357
+ pass
358
+ elif arg.startswith("--connect-timeout="):
359
+ try:
360
+ secs = float(arg[18:])
361
+ if secs > 0 and options.timeout_ms is None:
362
+ options.timeout_ms = int(secs * 1000)
363
+ except ValueError:
364
+ pass
365
+
366
+ elif arg == "-o" or arg == "--output":
367
+ i += 1
368
+ options.output_file = args[i] if i < len(args) else None
369
+ elif arg.startswith("--output="):
370
+ options.output_file = arg[9:]
371
+
372
+ elif arg == "-O" or arg == "--remote-name":
373
+ options.use_remote_name = True
374
+
375
+ elif arg == "-I" or arg == "--head":
376
+ options.head_only = True
377
+ options.method = "HEAD"
378
+
379
+ elif arg == "-i" or arg == "--include":
380
+ options.include_headers = True
381
+
382
+ elif arg == "-s" or arg == "--silent":
383
+ options.silent = True
384
+
385
+ elif arg == "-S" or arg == "--show-error":
386
+ options.show_error = True
387
+
388
+ elif arg == "-f" or arg == "--fail":
389
+ options.fail_silently = True
390
+
391
+ elif arg == "-L" or arg == "--location":
392
+ options.follow_redirects = True
393
+
394
+ elif arg == "--max-redirs":
395
+ i += 1
396
+ try:
397
+ options.max_redirects = int(args[i]) if i < len(args) else None
398
+ except ValueError:
399
+ pass
400
+
401
+ elif arg.startswith("--max-redirs="):
402
+ try:
403
+ options.max_redirects = int(arg[13:])
404
+ except ValueError:
405
+ pass
406
+
407
+ elif arg == "-w" or arg == "--write-out":
408
+ i += 1
409
+ options.write_out = args[i] if i < len(args) else None
410
+ elif arg.startswith("--write-out="):
411
+ options.write_out = arg[12:]
412
+
413
+ elif arg == "-v" or arg == "--verbose":
414
+ options.verbose = True
415
+
416
+ elif arg == "--compressed":
417
+ options.compressed = True
418
+
419
+ elif arg.startswith("--") and arg != "--":
420
+ return ExecResult(
421
+ stdout="",
422
+ stderr=f"curl: option {arg}: is unknown\n",
423
+ exit_code=2,
424
+ )
425
+ elif arg.startswith("-") and arg != "-":
426
+ # Handle combined short options like -sS
427
+ for c in arg[1:]:
428
+ if c == "s":
429
+ options.silent = True
430
+ elif c == "S":
431
+ options.show_error = True
432
+ elif c == "f":
433
+ options.fail_silently = True
434
+ elif c == "L":
435
+ options.follow_redirects = True
436
+ elif c == "I":
437
+ options.head_only = True
438
+ options.method = "HEAD"
439
+ elif c == "i":
440
+ options.include_headers = True
441
+ elif c == "O":
442
+ options.use_remote_name = True
443
+ elif c == "v":
444
+ options.verbose = True
445
+ else:
446
+ return ExecResult(
447
+ stdout="",
448
+ stderr=f"curl: option -{c}: is unknown\n",
449
+ exit_code=2,
450
+ )
451
+ elif not arg.startswith("-"):
452
+ options.url = arg
453
+
454
+ i += 1
455
+
456
+ return options
457
+
458
+
459
+ class CurlCommand:
460
+ """The curl command - transfer a URL."""
461
+
462
+ name = "curl"
463
+
464
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
465
+ """Execute the curl command."""
466
+ if "--help" in args:
467
+ return ExecResult(
468
+ stdout=(
469
+ "Usage: curl [OPTIONS] URL\n"
470
+ "Transfer a URL.\n\n"
471
+ "Options:\n"
472
+ " -X, --request METHOD HTTP method (GET, POST, PUT, DELETE, etc.)\n"
473
+ " -H, --header HEADER Add header (can be used multiple times)\n"
474
+ " -d, --data DATA HTTP POST data\n"
475
+ " --data-raw DATA HTTP POST data (no @ interpretation)\n"
476
+ " --data-binary DATA HTTP POST binary data\n"
477
+ " --data-urlencode DATA URL-encode and POST data\n"
478
+ " -F, --form NAME=VALUE Multipart form data\n"
479
+ " -u, --user USER:PASS HTTP authentication\n"
480
+ " -A, --user-agent STR Set User-Agent header\n"
481
+ " -e, --referer URL Set Referer header\n"
482
+ " -b, --cookie DATA Send cookies (name=value or @file)\n"
483
+ " -c, --cookie-jar FILE Save cookies to file\n"
484
+ " -T, --upload-file FILE Upload file (PUT)\n"
485
+ " -o, --output FILE Write output to file\n"
486
+ " -O, --remote-name Write to file named from URL\n"
487
+ " -I, --head Show headers only (HEAD request)\n"
488
+ " -i, --include Include response headers in output\n"
489
+ " -s, --silent Silent mode (no progress)\n"
490
+ " -S, --show-error Show errors even when silent\n"
491
+ " -f, --fail Fail silently on HTTP errors (no output)\n"
492
+ " -L, --location Follow redirects (default)\n"
493
+ " --max-redirs NUM Maximum redirects (default: 20)\n"
494
+ " -m, --max-time SECS Maximum time for request\n"
495
+ " --connect-timeout SECS Connection timeout\n"
496
+ " -w, --write-out FMT Output format after completion\n"
497
+ " -v, --verbose Verbose output\n"
498
+ " --help Display this help and exit\n"
499
+ ),
500
+ stderr="",
501
+ exit_code=0,
502
+ )
503
+
504
+ # Parse options
505
+ result = parse_options(args)
506
+ if isinstance(result, ExecResult):
507
+ return result
508
+
509
+ options = result
510
+
511
+ # Check for URL
512
+ if not options.url:
513
+ return ExecResult(
514
+ stdout="",
515
+ stderr="curl: no URL specified\n",
516
+ exit_code=2,
517
+ )
518
+
519
+ # Check for fetch function
520
+ if not ctx.fetch:
521
+ return ExecResult(
522
+ stdout="",
523
+ stderr="curl: internal error: fetch not available\n",
524
+ exit_code=1,
525
+ )
526
+
527
+ # Normalize URL
528
+ url = options.url
529
+ if not url.startswith("http://") and not url.startswith("https://"):
530
+ url = f"https://{url}"
531
+
532
+ try:
533
+ # Load cookies from file if specified
534
+ if options.cookie_file:
535
+ cookie_path = ctx.fs.resolve_path(ctx.cwd, options.cookie_file)
536
+ try:
537
+ cookie_content = await ctx.fs.read_file(cookie_path)
538
+ options.headers["Cookie"] = cookie_content.strip()
539
+ except Exception:
540
+ return ExecResult(
541
+ stdout="",
542
+ stderr=f"curl: (26) Failed to open/read cookie file: {options.cookie_file}: No such file or directory\n",
543
+ exit_code=26,
544
+ )
545
+
546
+ # Prepare body and headers
547
+ body, content_type = await self._prepare_request_body(options, ctx)
548
+ headers = self._prepare_headers(options, content_type)
549
+
550
+ # Add Accept-Encoding header if --compressed
551
+ if options.compressed and "Accept-Encoding" not in headers:
552
+ headers["Accept-Encoding"] = "gzip, deflate"
553
+
554
+ # Track timing for write-out variables
555
+ start_time = time.time()
556
+
557
+ # Make the request
558
+ fetch_options = {
559
+ "method": options.method,
560
+ "headers": headers if headers else None,
561
+ "body": body,
562
+ "followRedirects": options.follow_redirects,
563
+ "timeoutMs": options.timeout_ms,
564
+ }
565
+ if options.max_redirects is not None:
566
+ fetch_options["maxRedirects"] = options.max_redirects
567
+
568
+ result = await ctx.fetch(url, fetch_options)
569
+
570
+ # Calculate timing
571
+ elapsed_time = time.time() - start_time
572
+
573
+ # Decompress response if --compressed and Content-Encoding is set
574
+ response_headers = result.get("headers", {})
575
+ content_encoding = response_headers.get("content-encoding", "").lower()
576
+ body = result.get("body", "")
577
+
578
+ if options.compressed and content_encoding:
579
+ body = self._decompress_body(body, content_encoding)
580
+ result["body"] = body
581
+
582
+ # Save cookies if requested
583
+ await self._save_cookies(options, response_headers, ctx)
584
+
585
+ # Check for HTTP errors with -f/--fail
586
+ status = result.get("status", 0)
587
+ if options.fail_silently and status >= 400:
588
+ stderr = ""
589
+ if options.show_error or not options.silent:
590
+ stderr = f"curl: (22) The requested URL returned error: {status}\n"
591
+ return ExecResult(stdout="", stderr=stderr, exit_code=22)
592
+
593
+ # Calculate header size (approximate)
594
+ header_size = len(format_headers(response_headers)) + 20 # +20 for status line
595
+
596
+ # Calculate download speed (bytes/sec)
597
+ body_length = len(body) if isinstance(body, str) else len(body)
598
+ speed_download = body_length / elapsed_time if elapsed_time > 0 else 0.0
599
+
600
+ # Prepare write-out data
601
+ write_out_data = {
602
+ "status": status,
603
+ "headers": response_headers,
604
+ "url": result.get("url", url),
605
+ "body_length": body_length,
606
+ "redirect_count": result.get("redirectCount", 0),
607
+ "header_size": header_size,
608
+ "time_total": elapsed_time,
609
+ "speed_download": speed_download,
610
+ }
611
+
612
+ output = self._build_output(options, result, url, write_out_data)
613
+
614
+ # Write to file
615
+ if options.output_file or options.use_remote_name:
616
+ filename = options.output_file or extract_filename(url)
617
+ file_path = ctx.fs.resolve_path(ctx.cwd, filename)
618
+ body_content = "" if options.head_only else result.get("body", "")
619
+ await ctx.fs.write_file(file_path, body_content)
620
+
621
+ # When writing to file, don't output body unless verbose
622
+ if not options.verbose:
623
+ output = ""
624
+
625
+ # Add write-out after file write
626
+ if options.write_out:
627
+ output = apply_write_out(options.write_out, write_out_data)
628
+
629
+ return ExecResult(stdout=output, stderr="", exit_code=0)
630
+
631
+ except Exception as e:
632
+ message = str(e)
633
+
634
+ # Determine exit code based on error type
635
+ exit_code = 1
636
+ if "Network access denied" in message:
637
+ exit_code = 7 # CURLE_COULDNT_CONNECT
638
+ elif "HTTP method" in message and "not allowed" in message:
639
+ exit_code = 3 # CURLE_URL_MALFORMAT-like
640
+ elif "Redirect target not in allow-list" in message:
641
+ exit_code = 47 # CURLE_TOO_MANY_REDIRECTS-like
642
+ elif "Too many redirects" in message:
643
+ exit_code = 47
644
+ elif "aborted" in message or "timeout" in message.lower():
645
+ exit_code = 28 # CURLE_OPERATION_TIMEDOUT
646
+
647
+ # Silent mode suppresses error output unless -S is used
648
+ show_err = not options.silent or options.show_error
649
+ stderr = f"curl: ({exit_code}) {message}\n" if show_err else ""
650
+
651
+ return ExecResult(stdout="", stderr=stderr, exit_code=exit_code)
652
+
653
+ async def _prepare_request_body(
654
+ self,
655
+ options: CurlOptions,
656
+ ctx: CommandContext,
657
+ ) -> tuple[Optional[str], Optional[str]]:
658
+ """Prepare request body from options."""
659
+ # Handle -T/--upload-file
660
+ if options.upload_file:
661
+ file_path = ctx.fs.resolve_path(ctx.cwd, options.upload_file)
662
+ content = await ctx.fs.read_file(file_path)
663
+ return content, None
664
+
665
+ # Handle -F/--form multipart data
666
+ if options.form_fields:
667
+ file_contents = {}
668
+
669
+ # Read any file references
670
+ for field in options.form_fields:
671
+ if field.value.startswith("@") or field.value.startswith("<"):
672
+ file_path = ctx.fs.resolve_path(ctx.cwd, field.value[1:])
673
+ try:
674
+ content = await ctx.fs.read_file(file_path)
675
+ file_contents[field.value[1:]] = content
676
+ except Exception:
677
+ file_contents[field.value[1:]] = ""
678
+
679
+ body, boundary = generate_multipart_body(options.form_fields, file_contents)
680
+ return body, f"multipart/form-data; boundary={boundary}"
681
+
682
+ # Handle -d/--data variants
683
+ if options.data is not None:
684
+ return options.data, None
685
+
686
+ return None, None
687
+
688
+ def _prepare_headers(
689
+ self,
690
+ options: CurlOptions,
691
+ content_type: Optional[str],
692
+ ) -> dict[str, str]:
693
+ """Prepare request headers from options."""
694
+ headers = dict(options.headers)
695
+
696
+ # Add authentication header
697
+ if options.user:
698
+ encoded = base64.b64encode(options.user.encode()).decode()
699
+ headers["Authorization"] = f"Basic {encoded}"
700
+
701
+ # Set content type if needed and not already set
702
+ if content_type and "Content-Type" not in headers:
703
+ headers["Content-Type"] = content_type
704
+
705
+ return headers
706
+
707
+ async def _save_cookies(
708
+ self,
709
+ options: CurlOptions,
710
+ headers: dict[str, str],
711
+ ctx: CommandContext,
712
+ ) -> None:
713
+ """Save cookies from response to cookie jar file."""
714
+ if not options.cookie_jar:
715
+ return
716
+
717
+ set_cookie = headers.get("set-cookie")
718
+ if not set_cookie:
719
+ return
720
+
721
+ file_path = ctx.fs.resolve_path(ctx.cwd, options.cookie_jar)
722
+ await ctx.fs.write_file(file_path, set_cookie)
723
+
724
+ def _decompress_body(self, body: str | bytes, encoding: str) -> str:
725
+ """Decompress response body based on Content-Encoding."""
726
+ try:
727
+ # Handle bytes or string input
728
+ if isinstance(body, str):
729
+ body_bytes = body.encode("latin-1")
730
+ else:
731
+ body_bytes = body
732
+
733
+ if encoding == "gzip":
734
+ decompressed = gzip.decompress(body_bytes)
735
+ elif encoding == "deflate":
736
+ decompressed = zlib.decompress(body_bytes)
737
+ else:
738
+ return body if isinstance(body, str) else body.decode("utf-8", errors="replace")
739
+
740
+ return decompressed.decode("utf-8", errors="replace")
741
+ except Exception:
742
+ # If decompression fails, return original body
743
+ return body if isinstance(body, str) else body.decode("utf-8", errors="replace")
744
+
745
+ def _build_output(
746
+ self,
747
+ options: CurlOptions,
748
+ result: dict,
749
+ request_url: str,
750
+ write_out_data: dict | None = None,
751
+ ) -> str:
752
+ """Build output string from response."""
753
+ output = ""
754
+ status = result.get("status", 0)
755
+ status_text = result.get("statusText", "")
756
+ headers = result.get("headers", {})
757
+ body = result.get("body", "")
758
+ url = result.get("url", request_url)
759
+
760
+ # Verbose output
761
+ if options.verbose:
762
+ output += f"> {options.method} {request_url}\n"
763
+ for name, value in options.headers.items():
764
+ output += f"> {name}: {value}\n"
765
+ output += ">\n"
766
+ output += f"< HTTP/1.1 {status} {status_text}\n"
767
+ for name, value in headers.items():
768
+ output += f"< {name}: {value}\n"
769
+ output += "<\n"
770
+
771
+ # Include headers with -i/--include
772
+ if options.include_headers and not options.verbose:
773
+ output += f"HTTP/1.1 {status} {status_text}\r\n"
774
+ output += format_headers(headers)
775
+ output += "\r\n\r\n"
776
+
777
+ # Add body (unless head-only mode)
778
+ if not options.head_only:
779
+ output += body
780
+ elif options.include_headers or options.verbose:
781
+ # For HEAD, we already showed headers
782
+ pass
783
+ else:
784
+ # HEAD without -i shows headers
785
+ output += f"HTTP/1.1 {status} {status_text}\r\n"
786
+ output += format_headers(headers)
787
+ output += "\r\n"
788
+
789
+ # Write-out format
790
+ if options.write_out:
791
+ # Use provided write_out_data if available, otherwise build basic data
792
+ if write_out_data is None:
793
+ write_out_data = {
794
+ "status": status,
795
+ "headers": headers,
796
+ "url": url,
797
+ "body_length": len(body),
798
+ }
799
+ output += apply_write_out(options.write_out, write_out_data)
800
+
801
+ return output