@tiens.nguyen/gu-cli 1.0.835 → 1.0.837

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.
@@ -0,0 +1,675 @@
1
+ """POWERSHELL FOR run_command ON WINDOWS (task #374, GitHub #155) — what bash is on macOS/Linux.
2
+
3
+ On macOS and Linux a line that needs a shell runs through `bash -c` and every simple command in it
4
+ is checked first (task #359). Windows had Git Bash when Git for Windows was installed and NO shell
5
+ otherwise: a pipe, `;`, `$env:X = …` or any cmdlet (Get-ChildItem, Select-String) could not run,
6
+ and the model spent steps working around it. Claude Code runs PowerShell on Windows; so does gu now.
7
+
8
+ This module is the PURE half — no process is started here, so all of it is tested on any OS:
9
+
10
+ ps_scan(text) the line read the way PowerShell reads it: words, operators, and the bodies
11
+ of $( ), @( ), ( ) and { } (code that RUNS, so it is checked like the rest)
12
+ ps_commands(tokens) the simple commands, in the shape gonext_agent_chat's checks already take
13
+ ps_effects(cmd) what one command deletes, writes, or runs as code
14
+ ps_sequence(text) the line with && / || made explicit — Windows PowerShell 5.1 has neither —
15
+ and the exit status of the LAST statement kept in $__gu_ok
16
+ ps_wrapper(…) the outer script: UTF-8 out, the turn's folder and $env: kept between calls,
17
+ and the exit code written to a file a background shell outlives
18
+
19
+ The agent does the rest: the policy (gonext_agent_chat._ws_ps_command_risk) and the spawn.
20
+ """
21
+
22
+ import re
23
+
24
+ # PowerShell reads both kinds of typographic quotes as quotes.
25
+ _SQ = "'\u2018\u2019\u201a\u201b"
26
+ _DQ = '"\u201c\u201d\u201e'
27
+
28
+ # Separators between commands; `&` is a separator only AFTER a command (background), and the call
29
+ # operator at a command's start.
30
+ PS_SEPARATORS = {"|", "||", "&&", ";", "\n", "&"}
31
+ _REDIRECT_RE = re.compile(r"(?:[1-6*]?>>?)(?:&[12])?") # >, >>, 2>, 2>>, *>, 2>&1, *>&1
32
+
33
+
34
+ def ps_scan(command):
35
+ """Tokenise a PowerShell line. PURE. → dict, the same shape as the bash scan:
36
+
37
+ tokens — ("word", text, start, end, quoted) / ("op", text, start, end, False)
38
+ substs — [{"text", "tokens", "kind"}] for every $( ), @( ), ( ) and { } body
39
+ heredocs — how many here-strings (@' '@ / @" "@) were read
40
+ expands — True when a $variable, wildcard or ~ needs PowerShell to expand it
41
+ error — why the line cannot be run (an unclosed quote, here-string, bracket), or None
42
+ """
43
+ out = {"tokens": [], "substs": [], "heredocs": 0, "expands": False, "error": None}
44
+ try:
45
+ _scan_into(command or "", 0, out, out["tokens"], closer=None)
46
+ except ValueError as e:
47
+ out["error"] = str(e)
48
+ return out
49
+
50
+
51
+ def _scan_into(s, i, out, toks, closer):
52
+ """Scan from `i` into `toks`; with `closer` (")" or "}"), stop past the matching one."""
53
+ n = len(s)
54
+ st = {"word": None, "start": 0, "quoted": False}
55
+
56
+ def start_word(at):
57
+ if st["word"] is None:
58
+ st["word"], st["start"] = [], at
59
+
60
+ def end_word(at):
61
+ if st["word"] is None:
62
+ return
63
+ toks.append(("word", "".join(st["word"]), st["start"], at, st["quoted"]))
64
+ st["word"], st["quoted"] = None, False
65
+
66
+ def nested(j, kind, close): # j = index just past the opener → index past its closer
67
+ inner = []
68
+ k = _scan_into(s, j, out, inner, closer=close)
69
+ out["substs"].append({"text": s[j:k - 1], "tokens": inner, "kind": kind})
70
+ return k
71
+
72
+ def dollar(j): # a `$` at j → index past what it starts, or None for a literal $
73
+ if s.startswith("$(", j):
74
+ out["expands"] = True
75
+ return nested(j + 2, "$(", ")")
76
+ if s.startswith("${", j):
77
+ k = s.find("}", j + 2)
78
+ if k < 0:
79
+ raise ValueError("unclosed ${")
80
+ out["expands"] = True
81
+ return k + 1
82
+ m = re.match(r"\$(?:[A-Za-z_][\w]*:)?[\w?^$]+", s[j:])
83
+ if m:
84
+ out["expands"] = True
85
+ return j + m.end()
86
+ return None
87
+
88
+ def here_string(j, q): # j = index of "@" in @' or @" at the end of a line
89
+ nl = s.find("\n", j + 2)
90
+ if nl < 0 or s[j + 2:nl].strip(" \t\r"):
91
+ raise ValueError("a here-string's opening @" + q + " must end its line")
92
+ k = nl + 1
93
+ while True:
94
+ if k >= n:
95
+ raise ValueError("here-string never closed (" + q + "@ must start a line)")
96
+ e = s.find("\n", k)
97
+ line = s[k:] if e < 0 else s[k:e]
98
+ if line.rstrip("\r").startswith(q + "@"):
99
+ end = k + 2
100
+ break
101
+ if q == '"': # an expandable here-string runs its $( ) bodies
102
+ p = 0
103
+ while True:
104
+ p = line.find("$(", p)
105
+ if p < 0:
106
+ break
107
+ nested(k + p + 2, "$(", ")")
108
+ p += 2
109
+ k = n if e < 0 else e + 1
110
+ out["heredocs"] += 1
111
+ return end
112
+
113
+ while i < n:
114
+ c = s[i]
115
+ if c in " \t\r\f":
116
+ end_word(i)
117
+ i += 1
118
+ continue
119
+ if c == "\n":
120
+ end_word(i)
121
+ # A line ending with `|` continues on the next one.
122
+ if not (toks and toks[-1][0] == "op" and toks[-1][1] == "|"):
123
+ toks.append(("op", "\n", i, i + 1, False))
124
+ i += 1
125
+ continue
126
+ if c == "#" and st["word"] is None:
127
+ j = s.find("\n", i)
128
+ i = n if j < 0 else j
129
+ continue
130
+ if s.startswith("<#", i) and st["word"] is None:
131
+ j = s.find("#>", i + 2)
132
+ if j < 0:
133
+ raise ValueError("unclosed <# comment")
134
+ i = j + 2
135
+ continue
136
+ if c == "`":
137
+ if i + 1 < n and s[i + 1] in "\r\n": # line continuation
138
+ i += 3 if s.startswith("\r\n", i + 1) else 2
139
+ continue
140
+ start_word(i)
141
+ if i + 1 < n:
142
+ st["word"].append(s[i + 1])
143
+ st["quoted"] = True
144
+ i += 2
145
+ continue
146
+ if c == "@" and i + 1 < n and s[i + 1] in _SQ + _DQ and st["word"] is None:
147
+ # A here-string header: nothing may follow it on its line (PowerShell refuses too).
148
+ start_word(i)
149
+ k = here_string(i, "'" if s[i + 1] in _SQ else '"')
150
+ st["word"].extend(s[i:k])
151
+ st["quoted"] = True
152
+ i = k
153
+ continue
154
+ if c in _SQ:
155
+ start_word(i)
156
+ st["quoted"] = True
157
+ i += 1
158
+ while True:
159
+ if i >= n:
160
+ raise ValueError("unbalanced single quote")
161
+ if s[i] in _SQ:
162
+ if i + 1 < n and s[i + 1] in _SQ: # '' is one quote
163
+ st["word"].append("'")
164
+ i += 2
165
+ continue
166
+ i += 1
167
+ break
168
+ st["word"].append(s[i])
169
+ i += 1
170
+ continue
171
+ if c in _DQ:
172
+ start_word(i)
173
+ st["quoted"] = True
174
+ i += 1
175
+ while True:
176
+ if i >= n:
177
+ raise ValueError("unbalanced double quote")
178
+ d = s[i]
179
+ if d in _DQ:
180
+ if i + 1 < n and s[i + 1] in _DQ:
181
+ st["word"].append('"')
182
+ i += 2
183
+ continue
184
+ i += 1
185
+ break
186
+ if d == "`" and i + 1 < n:
187
+ st["word"].append(s[i + 1])
188
+ i += 2
189
+ continue
190
+ if d == "$":
191
+ k = dollar(i)
192
+ if k is not None:
193
+ st["word"].extend(s[i:k])
194
+ i = k
195
+ continue
196
+ st["word"].append(d)
197
+ i += 1
198
+ continue
199
+ if c == "$":
200
+ k = dollar(i)
201
+ if k is not None:
202
+ start_word(i)
203
+ st["word"].extend(s[i:k])
204
+ i = k
205
+ continue
206
+ if c == "@" and i + 1 < n and s[i + 1] in "({":
207
+ start_word(i)
208
+ k = nested(i + 2, "@" + s[i + 1], ")" if s[i + 1] == "(" else "}")
209
+ st["word"].extend(s[i:k])
210
+ i = k
211
+ continue
212
+ if c in "({":
213
+ start_word(i)
214
+ k = nested(i + 1, c, ")" if c == "(" else "}")
215
+ st["word"].extend(s[i:k])
216
+ i = k
217
+ continue
218
+ if c in ")}":
219
+ if closer == c:
220
+ end_word(i)
221
+ return i + 1
222
+ raise ValueError(f"unbalanced {c}")
223
+ if c == "[" and st["word"] is None: # a type literal: [IO.File]::ReadAllText(…)
224
+ k = s.find("]", i)
225
+ if k > 0:
226
+ start_word(i)
227
+ st["word"].extend(s[i:k + 1])
228
+ i = k + 1
229
+ continue
230
+ op = _op_at(s, i, st["word"])
231
+ if op:
232
+ if op[0] in "123456" and st["word"] is not None:
233
+ op = None # a digit inside a word ("a2>b" is not a redirect of fd 2)
234
+ if op:
235
+ end_word(i)
236
+ toks.append(("op", op, i, i + len(op), False))
237
+ i += len(op)
238
+ continue
239
+ start_word(i)
240
+ if c in "*?" or (c == "~" and not st["word"]):
241
+ out["expands"] = True
242
+ st["word"].append(c)
243
+ i += 1
244
+ end_word(n)
245
+ if closer:
246
+ raise ValueError(f"unbalanced {'(' if closer == ')' else '{'}")
247
+ return n
248
+
249
+
250
+ def _op_at(s, i, word):
251
+ for op in ("&&", "||"):
252
+ if s.startswith(op, i):
253
+ return op
254
+ m = _REDIRECT_RE.match(s, i)
255
+ if m and m.group(0):
256
+ return m.group(0)
257
+ if s[i] in "|;&<":
258
+ return s[i]
259
+ return None
260
+
261
+
262
+ # ------------------------------------------------------------------ commands -----------------
263
+
264
+ _ASSIGN_RE = re.compile(r"^\$[\w:{}?]+$")
265
+ _ASSIGN_OPS = {"=", "+=", "-=", "*=", "/=", "%=", "??="}
266
+
267
+
268
+ def ps_commands(tokens):
269
+ """Tokens → simple commands: [{"argv", "spans", "sep", "redirects", "assigns", "call", "background"}].
270
+
271
+ `call` — the command started with the call operator (`& 'C:\\x\\tool.exe' …`);
272
+ `assigns` — `$x = <command>` puts the command in argv and ("$x", "") in assigns;
273
+ `sep` — the separator before it ("" for the first); a trailing `&` sets `background`."""
274
+ cmds, cur, sep, i = [], None, "", 0
275
+
276
+ def new():
277
+ return {"argv": [], "spans": [], "quoted": [], "sep": sep, "redirects": [], "assigns": [],
278
+ "call": False, "background": False}
279
+
280
+ while i < len(tokens):
281
+ kind, text, a, b, quoted = tokens[i]
282
+ if kind == "op" and text == "&" and (cur is None or not cur["argv"]):
283
+ cur = cur or new()
284
+ cur["call"] = True
285
+ i += 1
286
+ continue
287
+ if kind == "op" and text in PS_SEPARATORS:
288
+ if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
289
+ if text == "&":
290
+ cur["background"] = True
291
+ cmds.append(cur)
292
+ cur, sep = None, text
293
+ i += 1
294
+ continue
295
+ if cur is None:
296
+ cur = new()
297
+ if kind == "op": # a redirect; `2>&1` has no target
298
+ nxt = tokens[i + 1] if i + 1 < len(tokens) else None
299
+ if "&" in text or text == "<":
300
+ cur["redirects"].append((text, ""))
301
+ i += 1
302
+ continue
303
+ tgt = nxt[1] if nxt and nxt[0] == "word" else ""
304
+ cur["redirects"].append((text, tgt))
305
+ i += 2 if tgt or (nxt and nxt[0] == "word") else 1
306
+ continue
307
+ if not cur["argv"] and not quoted and _ASSIGN_RE.match(text) and i + 1 < len(tokens) \
308
+ and tokens[i + 1][0] == "word" and tokens[i + 1][1] in _ASSIGN_OPS:
309
+ cur["assigns"].append((text, ""))
310
+ i += 2
311
+ continue
312
+ cur["argv"].append(text)
313
+ cur["spans"].append((a, b))
314
+ cur["quoted"].append(bool(quoted))
315
+ i += 1
316
+ if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
317
+ cmds.append(cur)
318
+ return cmds
319
+
320
+
321
+ def ps_all_commands(scan):
322
+ """(top-level commands, commands inside $( ), @( ), ( ) and { } bodies)."""
323
+ top = ps_commands(scan["tokens"])
324
+ inner = [c for sub in scan["substs"] for c in ps_commands(sub["tokens"])]
325
+ return top, inner
326
+
327
+
328
+ # ------------------------------------------------------------------ what a command does ------
329
+
330
+ # Aliases PowerShell ships (5.1 and 7), for the cmdlets the checks care about.
331
+ _ALIASES = {
332
+ "rm": "remove-item", "del": "remove-item", "erase": "remove-item", "rd": "remove-item",
333
+ "rmdir": "remove-item", "ri": "remove-item",
334
+ "cp": "copy-item", "copy": "copy-item", "cpi": "copy-item",
335
+ "mv": "move-item", "move": "move-item", "mi": "move-item",
336
+ "ni": "new-item", "md": "new-item", "mkdir": "new-item",
337
+ "sc": "set-content", "ac": "add-content", "clc": "clear-content",
338
+ "tee": "tee-object", "epcsv": "export-csv",
339
+ "cd": "set-location", "sl": "set-location", "chdir": "set-location", "pushd": "push-location",
340
+ "popd": "pop-location",
341
+ "iex": "invoke-expression", "icm": "invoke-command",
342
+ "iwr": "invoke-webrequest", "curl": "invoke-webrequest", "wget": "invoke-webrequest",
343
+ "irm": "invoke-restmethod",
344
+ "saps": "start-process", "start": "start-process",
345
+ "ls": "get-childitem", "dir": "get-childitem", "gci": "get-childitem",
346
+ "cat": "get-content", "gc": "get-content", "type": "get-content",
347
+ "echo": "write-output", "write": "write-output",
348
+ }
349
+
350
+
351
+ def ps_program(argv, quoted=False):
352
+ """The command's program, lower-case: a cmdlet's canonical name, a native program's base name
353
+ without .exe — or "" when the command starts with an expression (a string, a $variable).
354
+ `quoted`: the first word was quoted — a string, unless the call operator runs it."""
355
+ if not argv or quoted:
356
+ return ""
357
+ w = argv[0]
358
+ if not w or w[0] in "$('\"@[{" or re.match(r"^[\d.]+$", w):
359
+ return ""
360
+ base = re.split(r"[\\/]", w)[-1].lower()
361
+ for ext in (".exe", ".cmd", ".bat", ".com"):
362
+ if base.endswith(ext):
363
+ base = base[: -len(ext)]
364
+ break
365
+ return _ALIASES.get(base, base)
366
+
367
+
368
+ # Parameters that take a value, for the cmdlets whose paths are read. A prefix is enough in
369
+ # PowerShell (-Dest = -Destination), and `-Path:value` attaches the value.
370
+ _VALUE_PARAMS = ("path", "literalpath", "filepath", "destination", "destinationpath", "outfile",
371
+ "filter", "include", "exclude", "encoding", "value", "itemtype", "name",
372
+ "credential", "stream", "delimiter", "width", "inputobject", "newname", "target",
373
+ "argumentlist", "workingdirectory", "verb", "uri", "method", "body", "headers",
374
+ "scriptblock", "command", "computername", "type")
375
+ _PATH_POSITIONS = {
376
+ # cmdlet → ({named params that are paths written/removed}, positional indexes that are)
377
+ "remove-item": ({"path", "literalpath"}, "all"),
378
+ "set-content": ({"path", "literalpath"}, (0,)),
379
+ "add-content": ({"path", "literalpath"}, (0,)),
380
+ "clear-content": ({"path", "literalpath"}, "all"),
381
+ "out-file": ({"filepath", "literalpath"}, (0,)),
382
+ "tee-object": ({"filepath", "literalpath"}, (0,)),
383
+ "export-csv": ({"path", "literalpath"}, (0,)),
384
+ "new-item": ({"path"}, (0,)),
385
+ "copy-item": ({"destination"}, (1,)),
386
+ "move-item": ({"destination"}, (1,)),
387
+ "expand-archive": ({"destinationpath"}, (1,)),
388
+ "compress-archive": ({"destinationpath"}, (1,)),
389
+ "invoke-webrequest": ({"outfile"}, ()),
390
+ "invoke-restmethod": ({"outfile"}, ()),
391
+ }
392
+
393
+
394
+ # The value parameters of the cmdlets whose paths are read — PowerShell resolves an abbreviation
395
+ # among ONE cmdlet's parameters: `-Dest` is Copy-Item's -Destination although Expand-Archive has a
396
+ # -DestinationPath.
397
+ _CMDLET_PARAMS = {
398
+ "remove-item": ("path", "literalpath", "filter", "include", "exclude", "credential", "stream"),
399
+ "set-content": ("path", "literalpath", "value", "filter", "include", "exclude", "encoding", "stream"),
400
+ "add-content": ("path", "literalpath", "value", "filter", "include", "exclude", "encoding", "stream"),
401
+ "clear-content": ("path", "literalpath", "filter", "include", "exclude", "stream"),
402
+ "out-file": ("filepath", "literalpath", "encoding", "inputobject", "width"),
403
+ "tee-object": ("filepath", "literalpath", "inputobject", "variable", "encoding"),
404
+ "export-csv": ("path", "literalpath", "inputobject", "delimiter", "encoding"),
405
+ "new-item": ("path", "name", "itemtype", "value", "credential"),
406
+ "copy-item": ("path", "literalpath", "destination", "filter", "include", "exclude", "credential",
407
+ "fromsession", "tosession"),
408
+ "move-item": ("path", "literalpath", "destination", "filter", "include", "exclude", "credential"),
409
+ "expand-archive": ("path", "literalpath", "destinationpath"),
410
+ "compress-archive": ("path", "literalpath", "destinationpath", "compressionlevel"),
411
+ "invoke-webrequest": ("uri", "outfile", "method", "body", "headers", "infile", "credential",
412
+ "useragent", "contenttype", "timeoutsec"),
413
+ "invoke-restmethod": ("uri", "outfile", "method", "body", "headers", "infile", "credential",
414
+ "useragent", "contenttype", "timeoutsec"),
415
+ }
416
+
417
+
418
+ def _param(word, prog=""):
419
+ """`-Dest:x` → ("destination", "x", True); `-Recurse` → ("recurse", None, False).
420
+ The third item: the parameter takes a value. Not a parameter → None.
421
+ An abbreviation that fits SEVERAL value parameters still takes a value (PowerShell would
422
+ refuse it; reading the next word as its value never hides a path from the checks)."""
423
+ m = re.match(r"^-([A-Za-z][\w]*)(?::(.*))?$", word or "")
424
+ if not m:
425
+ return None
426
+ name = m.group(1).lower()
427
+ known = _CMDLET_PARAMS.get(prog) or _VALUE_PARAMS
428
+ full = [p for p in known if p == name] or [p for p in known if p.startswith(name)]
429
+ if len(full) == 1:
430
+ return full[0], m.group(2), True
431
+ return name, m.group(2), bool(full)
432
+
433
+
434
+ def _split_list(word):
435
+ """`a,b` / `a,` → ["a", "b"] — PowerShell's array argument."""
436
+ return [p for p in (x.strip() for x in word.split(",")) if p]
437
+
438
+
439
+ def ps_bound(argv, prog=None):
440
+ """argv[1:] → ({param: [values]}, [positionals], {switches}). `prog` (default: argv[0]'s
441
+ cmdlet) decides what an abbreviated parameter means."""
442
+ named, pos, switches = {}, [], set()
443
+ prog = ps_program(argv) if prog is None else prog
444
+ i = 1
445
+ while i < len(argv):
446
+ w = argv[i]
447
+ p = _param(w, prog)
448
+ if p:
449
+ name, attached, takes = p
450
+ if attached is not None:
451
+ named.setdefault(name, []).extend(_split_list(attached))
452
+ elif takes and i + 1 < len(argv):
453
+ named.setdefault(name, []).extend(_split_list(argv[i + 1]))
454
+ i += 1
455
+ elif not takes:
456
+ switches.add(name)
457
+ i += 1
458
+ continue
459
+ pos.extend(_split_list(w))
460
+ i += 1
461
+ return named, pos, switches
462
+
463
+
464
+ def ps_effects(cmd):
465
+ """What one command does that the policy weighs → dict:
466
+ deletes / writes — the paths it removes / writes (redirect targets included);
467
+ runs_text — it runs TEXT as code (Invoke-Expression, [ScriptBlock]::Create, -EncodedCommand);
468
+ dynamic — the program is only known at run time (`& $x`, `& (…)`);
469
+ destructive — wipes disks, powers off, or removes recursively/by force."""
470
+ argv = cmd["argv"]
471
+ prog = ps_program(argv, bool(cmd.get("quoted") and cmd["quoted"][0] and not cmd.get("call")))
472
+ named, pos, switches = ps_bound(argv, prog)
473
+ eff = {"program": prog, "deletes": [], "writes": [], "runs_text": "", "dynamic": False,
474
+ "destructive": False}
475
+ rule = _PATH_POSITIONS.get(prog)
476
+ if rule:
477
+ params, positions = rule
478
+ paths = [v for k in params for v in named.get(k, [])]
479
+ if positions == "all":
480
+ paths += pos
481
+ else:
482
+ paths += [pos[i] for i in positions if i < len(pos)]
483
+ (eff["deletes"] if prog == "remove-item" else eff["writes"]).extend(paths)
484
+ if prog == "move-item": # a move removes its source
485
+ eff["deletes"] += named.get("path", []) + named.get("literalpath", []) + pos[:1]
486
+ eff["writes"] += [t for op, t in cmd["redirects"] if t and op not in ("<",) and "&" not in op]
487
+ joined = " ".join(argv)
488
+ if prog == "invoke-expression":
489
+ eff["runs_text"] = "Invoke-Expression"
490
+ elif re.search(r"\[scriptblock\]::create", joined, re.I):
491
+ eff["runs_text"] = "[ScriptBlock]::Create"
492
+ elif prog in ("powershell", "pwsh") and any(
493
+ re.match(r"^-(e|ec|enc|encodedcommand)$", a, re.I) for a in argv[1:]):
494
+ eff["runs_text"] = "an -EncodedCommand"
495
+ if cmd.get("call") and argv and argv[0][:1] in ("$", "("):
496
+ eff["dynamic"] = True
497
+ if prog in ("format-volume", "clear-disk", "initialize-disk", "remove-partition",
498
+ "stop-computer", "restart-computer", "format", "diskpart", "shutdown"):
499
+ eff["destructive"] = True
500
+ if prog == "remove-item" and switches & {"recurse", "r", "force", "rf", "fr"}:
501
+ eff["destructive"] = True
502
+ return eff
503
+
504
+
505
+ # Runners that execute their stdin as code when given no script (`… | iex`, `… | pwsh -`).
506
+ PS_INTERPRETERS = {"invoke-expression", "powershell", "pwsh", "cmd", "bash", "sh", "wsl", "python",
507
+ "python3", "py", "pythonw", "node", "perl", "ruby", "php"}
508
+
509
+
510
+ def ps_is_literal(cmd):
511
+ """A pipeline stage that is only a string or a here-string the line itself wrote — the
512
+ PowerShell form of a heredoc: `@' … '@ | py -3 -` runs code the model wrote, in plain view."""
513
+ a = cmd["argv"]
514
+ q = cmd.get("quoted") or []
515
+ return len(a) == 1 and not cmd.get("call") and bool(q and q[0]) and "$(" not in a[0]
516
+
517
+
518
+ # ------------------------------------------------------------------ running it ---------------
519
+
520
+ _CONTINUES = re.compile(r"^\s*(?:(?:else|elseif|catch|finally|until|while)\b|\{)", re.I)
521
+
522
+
523
+ def ps_sequence(command, scan=None, legacy=False):
524
+ """The line with its top-level `;`, newlines, && and || made explicit, so that
525
+ Windows PowerShell 5.1 (no && / ||) runs `a && b` as bash would, and $__gu_ok holds whether
526
+ the last statement that ran succeeded — the exit code. Text inside quotes, here-strings and
527
+ brackets is never split; `else`/`catch`/… stays with the statement before it.
528
+
529
+ legacy=True (Windows PowerShell 5.1): there `2>&1` turns a program's stderr lines into error
530
+ records — red boilerplate in the output, and $? false although the program exited 0
531
+ (`git push 2>&1`). On the LAST stage of a pipeline it changes nothing else (gu already merges
532
+ stderr into the output), so it is dropped there; elsewhere a "failure" that is only such a
533
+ record, with exit code 0, counts as the success it was."""
534
+ scan = scan or ps_scan(command)
535
+ s = command or ""
536
+ if legacy:
537
+ toks = scan["tokens"]
538
+ drop = []
539
+ for k, t in enumerate(toks):
540
+ if t[0] == "op" and t[1] in ("2>&1", "*>&1"):
541
+ nxt = next((u for u in toks[k + 1:] if u[0] == "op" and u[1] in PS_SEPARATORS), None)
542
+ if not nxt or nxt[1] != "|":
543
+ drop.append((t[2], t[3]))
544
+ if drop:
545
+ for a, b in sorted(drop, reverse=True):
546
+ s = s[:a] + " " * (b - a) + s[b:] # same length: every other position still holds
547
+ ok = "\n$__gu_ok = $?"
548
+ if legacy:
549
+ ok += ("\nif (-not $__gu_ok -and $LASTEXITCODE -eq 0 -and $Error.Count -gt $__gu_n -and "
550
+ "\"$($Error[0].FullyQualifiedErrorId)\" -like 'NativeCommandError*') { $__gu_ok = $true }")
551
+ cuts = [(t[2], t[3], t[1]) for t in scan["tokens"]
552
+ if t[0] == "op" and t[1] in ("&&", "||", ";", "\n")]
553
+ parts, prev, sep = [], 0, ";"
554
+ for a, b, op in cuts:
555
+ seg = s[prev:a]
556
+ nxt_start = b
557
+ if op in (";", "\n") and _CONTINUES.match(s[nxt_start:]):
558
+ continue # `}` newline `else {` — one statement
559
+ parts.append((sep, seg))
560
+ sep, prev = op, b
561
+ parts.append((sep, s[prev:]))
562
+ lines = []
563
+ for sep, seg in parts:
564
+ if not seg.strip():
565
+ continue
566
+ body = ("$__gu_n = $Error.Count\n" if legacy else "") + seg.strip(" \t\r\n") + ok
567
+ if sep == "&&" and lines:
568
+ lines.append("if ($__gu_ok) {\n" + body + "\n}")
569
+ elif sep == "||" and lines:
570
+ lines.append("if (-not $__gu_ok) {\n" + body + "\n}")
571
+ else:
572
+ lines.append(body)
573
+ return "$__gu_ok = $true\n" + "\n".join(lines) + "\n"
574
+
575
+
576
+ _VAR_IN_PATH = re.compile(r"\$(?:\{env:([^}]+)\}|env:([A-Za-z_][\w()]*)|([A-Za-z_]\w*))", re.I)
577
+
578
+
579
+ def ps_expand_path(p, env, home, cwd=""):
580
+ """A path as PowerShell will read it: $env:X / ${env:X}, $HOME, $PWD and a leading ~ filled
581
+ in — or None when it holds anything else that only exists at run time (then the caller must
582
+ treat it as anywhere, i.e. outside the project)."""
583
+ lower_env = {k.lower(): v for k, v in (env or {}).items()}
584
+ unknown = []
585
+
586
+ def sub(m):
587
+ name = m.group(1) or m.group(2)
588
+ if name:
589
+ v = lower_env.get(name.lower())
590
+ else:
591
+ v = {"home": home, "pwd": cwd}.get(m.group(3).lower())
592
+ if v is None or v == "":
593
+ unknown.append(m.group(0))
594
+ return m.group(0)
595
+ return v
596
+
597
+ out = _VAR_IN_PATH.sub(sub, p or "")
598
+ if unknown or "$(" in out:
599
+ return None
600
+ if out == "~" or out.startswith(("~/", "~\\")):
601
+ out = home + out[1:]
602
+ return out
603
+
604
+
605
+ def _psq(s):
606
+ """A PowerShell single-quoted literal."""
607
+ return "'" + str(s).replace("'", "''") + "'"
608
+
609
+
610
+ def ps_wrapper(cmd_file, exit_file, state_file):
611
+ """The outer script PowerShell runs with -File. It reads the (sequenced) line from `cmd_file`
612
+ and dot-sources it — a parse error is then an error this script reports, not one that skips
613
+ the exit file — and, whatever happens, writes the exit code to `exit_file`, the folder to
614
+ `state_file`.cwd and the $env: variables the line changed to `state_file` (JSON), which the
615
+ next call restores: the turn keeps its `cd` and `$env:X = …` the way bash keeps its exports."""
616
+ return "\n".join([
617
+ "$ErrorActionPreference = 'Continue'",
618
+ "$ProgressPreference = 'SilentlyContinue'",
619
+ "try { $PSStyle.OutputRendering = 'PlainText' } catch {}",
620
+ "try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {}",
621
+ "$OutputEncoding = New-Object System.Text.UTF8Encoding $false",
622
+ f"$__gu_exit = {_psq(exit_file)}",
623
+ f"$__gu_state = {_psq(state_file)}",
624
+ "if (Test-Path -LiteralPath $__gu_state) {",
625
+ " try {",
626
+ " $__gu_saved = Get-Content -LiteralPath $__gu_state -Raw -Encoding UTF8 | ConvertFrom-Json",
627
+ " foreach ($__gu_p in $__gu_saved.PSObject.Properties) {",
628
+ " if ($null -eq $__gu_p.Value) { Remove-Item -LiteralPath ('env:' + $__gu_p.Name) -ErrorAction SilentlyContinue }",
629
+ " else { Set-Item -LiteralPath ('env:' + $__gu_p.Name) -Value $__gu_p.Value }",
630
+ " }",
631
+ " } catch {}",
632
+ "}",
633
+ "$__gu_env0 = @{}",
634
+ "foreach ($__gu_e in Get-ChildItem env:) { $__gu_env0[$__gu_e.Name] = $__gu_e.Value }",
635
+ "$__gu_rc = 1",
636
+ "$__gu_ok = $false",
637
+ "$global:LASTEXITCODE = 0",
638
+ "try {",
639
+ f" $__gu_text = [System.IO.File]::ReadAllText({_psq(cmd_file)}, [System.Text.Encoding]::UTF8)",
640
+ " . ([ScriptBlock]::Create($__gu_text))",
641
+ " if ($__gu_ok) { $__gu_rc = 0 } elseif ($LASTEXITCODE) { $__gu_rc = $LASTEXITCODE } else { $__gu_rc = 1 }",
642
+ "} catch {",
643
+ " [Console]::Out.Flush()",
644
+ " Write-Output ($_ | Out-String).TrimEnd()",
645
+ " $__gu_rc = 1",
646
+ "} finally {",
647
+ " try { Set-Content -LiteralPath $__gu_exit -Value $__gu_rc -Encoding Ascii } catch {}",
648
+ " try {",
649
+ " $__gu_keep = @{}",
650
+ " if (Test-Path -LiteralPath $__gu_state) {",
651
+ " $__gu_old = Get-Content -LiteralPath $__gu_state -Raw -Encoding UTF8 | ConvertFrom-Json",
652
+ " foreach ($__gu_p in $__gu_old.PSObject.Properties) { $__gu_keep[$__gu_p.Name] = $__gu_p.Value }",
653
+ " }",
654
+ " $__gu_now = @{}",
655
+ " foreach ($__gu_e in Get-ChildItem env:) { $__gu_now[$__gu_e.Name] = $__gu_e.Value }",
656
+ " foreach ($__gu_k in $__gu_now.Keys) { if ($__gu_env0[$__gu_k] -ne $__gu_now[$__gu_k]) { $__gu_keep[$__gu_k] = $__gu_now[$__gu_k] } }",
657
+ " foreach ($__gu_k in $__gu_env0.Keys) { if (-not $__gu_now.ContainsKey($__gu_k)) { $__gu_keep[$__gu_k] = $null } }",
658
+ " $__gu_keep.Remove('PWD') | Out-Null",
659
+ " ConvertTo-Json -InputObject $__gu_keep -Compress | Set-Content -LiteralPath $__gu_state -Encoding UTF8",
660
+ " $__gu_loc = Get-Location",
661
+ " if ($__gu_loc.Provider.Name -eq 'FileSystem') {",
662
+ " Set-Content -LiteralPath ($__gu_state + '.cwd') -Value $__gu_loc.ProviderPath -Encoding UTF8",
663
+ " }",
664
+ " } catch {}",
665
+ "}",
666
+ "exit $__gu_rc",
667
+ "",
668
+ ])
669
+
670
+
671
+ def ps_argv(exe, wrapper_file):
672
+ """How the wrapper is started: no profile (a user's profile can print, prompt or slow every
673
+ command), no prompts, and this process's execution policy only — never the machine's."""
674
+ return [exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
675
+ "-File", wrapper_file]