@tiens.nguyen/gu-cli 1.0.830 → 1.0.832
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.
- package/gonext_agent_chat.py +238 -5
- package/gu-repl.mjs +12 -2
- package/package.json +1 -1
- package/shell-details.mjs +36 -3
package/gonext_agent_chat.py
CHANGED
|
@@ -7711,13 +7711,13 @@ def _shell_commands(tokens):
|
|
|
7711
7711
|
while i < len(tokens):
|
|
7712
7712
|
kind, text, a, b, quoted = tokens[i]
|
|
7713
7713
|
if kind == "op" and text in _SHELL_SEPARATORS:
|
|
7714
|
-
if cur and (cur["argv"] or cur["redirects"]):
|
|
7714
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
7715
7715
|
cmds.append(cur)
|
|
7716
7716
|
cur, sep = None, text
|
|
7717
7717
|
i += 1
|
|
7718
7718
|
continue
|
|
7719
7719
|
if cur is None:
|
|
7720
|
-
cur = {"argv": [], "spans": [], "sep": sep, "redirects": []}
|
|
7720
|
+
cur = {"argv": [], "spans": [], "sep": sep, "redirects": [], "assigns": []}
|
|
7721
7721
|
if kind == "op":
|
|
7722
7722
|
nxt = tokens[i + 1] if i + 1 < len(tokens) else None
|
|
7723
7723
|
tgt = nxt[1] if nxt and nxt[0] == "word" else ""
|
|
@@ -7725,16 +7725,73 @@ def _shell_commands(tokens):
|
|
|
7725
7725
|
i += 2 if tgt or (nxt and nxt[0] == "word") else 1
|
|
7726
7726
|
continue
|
|
7727
7727
|
if not cur["argv"] and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", text):
|
|
7728
|
+
cur["assigns"].append(tuple(text.split("=", 1)))
|
|
7728
7729
|
i += 1
|
|
7729
7730
|
continue
|
|
7730
7731
|
cur["argv"].append(text)
|
|
7731
7732
|
cur["spans"].append((a, b))
|
|
7732
7733
|
i += 1
|
|
7733
|
-
if cur and (cur["argv"] or cur["redirects"]):
|
|
7734
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
7734
7735
|
cmds.append(cur)
|
|
7735
7736
|
return cmds
|
|
7736
7737
|
|
|
7737
7738
|
|
|
7739
|
+
# A RUNNER IN A VARIABLE THAT THE LINE ITSELF SET (#362). `U=/Applications/…/Unity && $U -batchmode
|
|
7740
|
+
# -runTests …` asked "the program to run is only known at run time" — but it is known: the same
|
|
7741
|
+
# line assigned it a literal. The check now resolves that case and judges the REAL program, by
|
|
7742
|
+
# re-scanning a copy of the line with the literal in place — so every rule (privilege, denylist,
|
|
7743
|
+
# allowlist, deletes) sees it: `S=sudo && $S rm -rf /` is still sudo. Only a top-level assignment
|
|
7744
|
+
# whose value has no $, `, glob or leading ~, made before the use and not inside a pipeline (a
|
|
7745
|
+
# subshell); anything else keeps asking. bash still runs the line exactly as written.
|
|
7746
|
+
# Anything else that can set or clear a variable makes the value unknowable from the text.
|
|
7747
|
+
_SHELL_VAR_CHANGERS = {"unset", "read", "declare", "typeset", "local", "readonly", "source", ".",
|
|
7748
|
+
"eval", "for", "select", "mapfile", "readarray", "getopts", "printf", "let"}
|
|
7749
|
+
_SHELL_VAR_RUNNER_RE = re.compile(r"^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$")
|
|
7750
|
+
|
|
7751
|
+
|
|
7752
|
+
def _shell_literal_value(v: str) -> bool:
|
|
7753
|
+
return bool(v) and not re.search(r"[$`*?\[]", v) and not v.startswith("~")
|
|
7754
|
+
|
|
7755
|
+
|
|
7756
|
+
def _shell_resolve_runner_vars(command: str, scan) -> str:
|
|
7757
|
+
"""`command` with each top-level `$X` runner replaced by X's literal value, or "" when there
|
|
7758
|
+
is nothing this line can resolve. For CHECKING only — never executed."""
|
|
7759
|
+
import shlex as _shlex
|
|
7760
|
+
if not scan or scan.get("error"):
|
|
7761
|
+
return ""
|
|
7762
|
+
cmds = _shell_commands(scan["tokens"])
|
|
7763
|
+
if any(c["argv"] and _shell_prog_name(c["argv"][0]) in _SHELL_VAR_CHANGERS for c in cmds):
|
|
7764
|
+
return ""
|
|
7765
|
+
env, edits = {}, []
|
|
7766
|
+
for k, c in enumerate(cmds):
|
|
7767
|
+
in_pipe = c["sep"] in ("|", "|&") or (k + 1 < len(cmds) and cmds[k + 1]["sep"] in ("|", "|&"))
|
|
7768
|
+
a = c["argv"]
|
|
7769
|
+
pairs = []
|
|
7770
|
+
if not a:
|
|
7771
|
+
pairs = list(c.get("assigns") or [])
|
|
7772
|
+
elif a[0] == "export":
|
|
7773
|
+
pairs = [tuple(x.split("=", 1)) for x in a[1:] if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", x)]
|
|
7774
|
+
if pairs:
|
|
7775
|
+
for name, val in pairs:
|
|
7776
|
+
if not in_pipe and _shell_literal_value(val):
|
|
7777
|
+
env[name] = val
|
|
7778
|
+
else:
|
|
7779
|
+
env.pop(name, None)
|
|
7780
|
+
continue
|
|
7781
|
+
m = _SHELL_VAR_RUNNER_RE.match(a[0]) if a else None
|
|
7782
|
+
if m and (m.group(1) or m.group(2)) in env:
|
|
7783
|
+
start, end = c["spans"][0]
|
|
7784
|
+
val = env[m.group(1) or m.group(2)]
|
|
7785
|
+
# "$U" is one word; a bare $U is WORD-SPLIT by bash — `U="sudo rm" && $U -rf /` runs
|
|
7786
|
+
# sudo. Substituted raw, the re-scan splits it the same way and sees sudo.
|
|
7787
|
+
edits.append(((start, end), _shlex.quote(val) if command[start:end].startswith('"') else val))
|
|
7788
|
+
if not edits:
|
|
7789
|
+
return ""
|
|
7790
|
+
for (start, end), val in sorted(edits, reverse=True):
|
|
7791
|
+
command = command[:start] + val + command[end:]
|
|
7792
|
+
return command
|
|
7793
|
+
|
|
7794
|
+
|
|
7738
7795
|
def _shell_needed(scan):
|
|
7739
7796
|
"""Does this line need a shell to mean what it says? False → run it as argv, as before."""
|
|
7740
7797
|
return bool(scan["heredocs"] or scan["substs"] or scan["expands"]
|
|
@@ -7999,6 +8056,111 @@ _HEREDOC_HINT = ("\n\nSHELL QUOTING broke this line. For text with quotes or lin
|
|
|
7999
8056
|
"run_command(\"\"\"git commit -F - <<'EOF'\n<message>\nEOF\"\"\") — nothing in the "
|
|
8000
8057
|
"message needs escaping.")
|
|
8001
8058
|
|
|
8059
|
+
# A PIPE HIDES THE EXIT CODE OF EVERYTHING BEFORE IT (#360 review, turn 3c06654e). The model ran
|
|
8060
|
+
# `python3 -m pytest … | tail -5 && git add … && git commit …`; pytest was not installed, `tail`
|
|
8061
|
+
# exited 0, and the commit went in untested. Global `set -o pipefail` would fix that and break
|
|
8062
|
+
# `git log | head -5` (SIGPIPE → 141) everywhere, so the line runs as written and gu READS the
|
|
8063
|
+
# output instead: a pipe line that exited 0 while printing an unmistakable failure is reported
|
|
8064
|
+
# as one. Markers are line-anchored and specific, so ordinary output containing "error" passes.
|
|
8065
|
+
_PIPE_FAIL_RE = re.compile(
|
|
8066
|
+
r"^.*(?:No module named \S+|ModuleNotFoundError: .*|: command not found|: not found$"
|
|
8067
|
+
r"|^Traceback \(most recent call last\):|^fatal: .*|^error: .*|^npm ERR! .*"
|
|
8068
|
+
r"|^E: Unable to locate package .*|: No such file or directory$)",
|
|
8069
|
+
re.M)
|
|
8070
|
+
|
|
8071
|
+
|
|
8072
|
+
def _pipe_hidden_failure(scan, out: str) -> str:
|
|
8073
|
+
"""The failure line a pipe hid, or "" — only for a line that has a `|`."""
|
|
8074
|
+
if not scan or not any(t[0] == "op" and t[1] == "|" for t in scan.get("tokens") or []):
|
|
8075
|
+
return ""
|
|
8076
|
+
m = _PIPE_FAIL_RE.search(out or "")
|
|
8077
|
+
return m.group(0).strip()[:200] if m else ""
|
|
8078
|
+
|
|
8079
|
+
|
|
8080
|
+
def _pipe_hidden_failure_note(line: str) -> str:
|
|
8081
|
+
return ("\n\nWARNING — A FAILURE WAS HIDDEN BY A PIPE: the output shows "
|
|
8082
|
+
f"'{line}', but a pipeline reports only its LAST command's exit code, so this "
|
|
8083
|
+
"line counted as success and every part after `&&` RAN ANYWAY. Check what those "
|
|
8084
|
+
"parts did before relying on them. To chain on a check, run it without a pipe, or "
|
|
8085
|
+
"start the line with `set -o pipefail;`.")
|
|
8086
|
+
|
|
8087
|
+
|
|
8088
|
+
# THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
|
|
8089
|
+
# whose package.json says `python3 -m unittest discover tests -v && node --test` — pytest was not
|
|
8090
|
+
# even installed. The manifests already say how each part of the project is tested; reading them
|
|
8091
|
+
# costs no tool call. Depth ≤ 3 (a monorepo's packages), at most 8, nearest first.
|
|
8092
|
+
_TEST_SKIP_DIRS = {"node_modules", "dist", "build", "out", "target", "vendor", "venv", ".venv",
|
|
8093
|
+
"__pycache__", "server-bundle", "coverage"}
|
|
8094
|
+
|
|
8095
|
+
|
|
8096
|
+
def _project_test_commands(root: str, max_depth: int = 3, limit: int = 8) -> list:
|
|
8097
|
+
"""[(relative folder, command)] from package.json scripts.test, a Makefile `test:` target,
|
|
8098
|
+
pytest config, Cargo.toml and go.mod. Never raises."""
|
|
8099
|
+
import json as _json
|
|
8100
|
+
found = []
|
|
8101
|
+
try:
|
|
8102
|
+
root = os.path.abspath(root)
|
|
8103
|
+
except Exception: # noqa: BLE001
|
|
8104
|
+
return []
|
|
8105
|
+
queue = [(root, 0)]
|
|
8106
|
+
while queue and len(found) < limit:
|
|
8107
|
+
d, depth = queue.pop(0)
|
|
8108
|
+
try:
|
|
8109
|
+
names = set(os.listdir(d))
|
|
8110
|
+
except OSError:
|
|
8111
|
+
continue
|
|
8112
|
+
rel = os.path.relpath(d, root)
|
|
8113
|
+
rel = "." if rel == "." else rel.replace(os.sep, "/")
|
|
8114
|
+
cmd = ""
|
|
8115
|
+
if "package.json" in names:
|
|
8116
|
+
try:
|
|
8117
|
+
with open(os.path.join(d, "package.json"), encoding="utf-8") as fh:
|
|
8118
|
+
t = ((_json.load(fh) or {}).get("scripts") or {}).get("test") or ""
|
|
8119
|
+
if t and "no test specified" not in t:
|
|
8120
|
+
cmd = f"npm test ({_clip(t, 110)})"
|
|
8121
|
+
except Exception: # noqa: BLE001
|
|
8122
|
+
pass
|
|
8123
|
+
if not cmd and "Makefile" in names:
|
|
8124
|
+
try:
|
|
8125
|
+
with open(os.path.join(d, "Makefile"), encoding="utf-8", errors="replace") as fh:
|
|
8126
|
+
if re.search(r"(?m)^test\s*:", fh.read(200_000)):
|
|
8127
|
+
cmd = "make test"
|
|
8128
|
+
except OSError:
|
|
8129
|
+
pass
|
|
8130
|
+
if not cmd and ("pytest.ini" in names or "conftest.py" in names):
|
|
8131
|
+
cmd = "python3 -m pytest"
|
|
8132
|
+
if not cmd and "pyproject.toml" in names:
|
|
8133
|
+
try:
|
|
8134
|
+
with open(os.path.join(d, "pyproject.toml"), encoding="utf-8", errors="replace") as fh:
|
|
8135
|
+
if "[tool.pytest" in fh.read(200_000):
|
|
8136
|
+
cmd = "python3 -m pytest"
|
|
8137
|
+
except OSError:
|
|
8138
|
+
pass
|
|
8139
|
+
if not cmd and "Cargo.toml" in names:
|
|
8140
|
+
cmd = "cargo test"
|
|
8141
|
+
if not cmd and "go.mod" in names:
|
|
8142
|
+
cmd = "go test ./..."
|
|
8143
|
+
if cmd:
|
|
8144
|
+
found.append((rel, cmd))
|
|
8145
|
+
if depth < max_depth:
|
|
8146
|
+
for n in sorted(names):
|
|
8147
|
+
if n.startswith(".") or n in _TEST_SKIP_DIRS:
|
|
8148
|
+
continue
|
|
8149
|
+
p = os.path.join(d, n)
|
|
8150
|
+
if os.path.isdir(p) and not os.path.islink(p):
|
|
8151
|
+
queue.append((p, depth + 1))
|
|
8152
|
+
return found
|
|
8153
|
+
|
|
8154
|
+
|
|
8155
|
+
def _test_commands_line(root: str) -> str:
|
|
8156
|
+
cmds = _project_test_commands(root) if root else []
|
|
8157
|
+
if not cmds:
|
|
8158
|
+
return ""
|
|
8159
|
+
return (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
|
|
8160
|
+
"(a missing pytest fails silently behind a pipe): "
|
|
8161
|
+
+ "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
|
|
8162
|
+
|
|
8163
|
+
|
|
8002
8164
|
# bash's words for a line whose quotes do not pair up.
|
|
8003
8165
|
_SHELL_QUOTE_ERR_RE = re.compile(
|
|
8004
8166
|
r"unexpected EOF while looking for matching|syntax error near unexpected token|"
|
|
@@ -9059,6 +9221,55 @@ def _shell_or_server(shells: list, servers: list, key: str):
|
|
|
9059
9221
|
return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
|
|
9060
9222
|
|
|
9061
9223
|
|
|
9224
|
+
# A SHELL THAT WRITES ITS OWN LOG (#362). Unity `-batchmode … -logFile Logs/all.log` prints nothing
|
|
9225
|
+
# for its whole 5-minute run — everything goes to that file — so the shell's output read "No output
|
|
9226
|
+
# available" until the end. When a shell has no output of its own, the file its command names is
|
|
9227
|
+
# the next best thing: an explicit log flag first, then `tee`, then the last `>`/`>>` redirect.
|
|
9228
|
+
_LOG_FLAG_RE = re.compile(r"^--?(?:log-?file|logfile|log-path|log)(?:=(.+))?$", re.I)
|
|
9229
|
+
|
|
9230
|
+
|
|
9231
|
+
def _shell_log_file(command: str, workdir: str = "") -> str:
|
|
9232
|
+
"""The log file a command line writes to, as an absolute path that exists — or ""."""
|
|
9233
|
+
scan = _shell_scan(command or "")
|
|
9234
|
+
if scan.get("error"):
|
|
9235
|
+
return ""
|
|
9236
|
+
flag = tee = redir = ""
|
|
9237
|
+
for c in _shell_commands(scan["tokens"]):
|
|
9238
|
+
a = c["argv"]
|
|
9239
|
+
for i, w in enumerate(a):
|
|
9240
|
+
m = _LOG_FLAG_RE.match(w)
|
|
9241
|
+
if m:
|
|
9242
|
+
flag = m.group(1) or (a[i + 1] if i + 1 < len(a) else "") or flag
|
|
9243
|
+
if a and _shell_prog_name(a[0]) == "tee":
|
|
9244
|
+
files = [x for x in a[1:] if not x.startswith("-")]
|
|
9245
|
+
tee = files[-1] if files else tee
|
|
9246
|
+
for op, tgt in c["redirects"]:
|
|
9247
|
+
if op in (">", ">>", "&>", "&>>", "1>", "1>>") and tgt and not tgt.startswith("&"):
|
|
9248
|
+
redir = tgt
|
|
9249
|
+
for cand in (flag, tee, redir):
|
|
9250
|
+
if not cand or cand in ("/dev/null", "-"):
|
|
9251
|
+
continue
|
|
9252
|
+
path = cand.replace("$PWD", workdir or "").replace("${PWD}", workdir or "")
|
|
9253
|
+
path = os.path.expanduser(path)
|
|
9254
|
+
if not os.path.isabs(path):
|
|
9255
|
+
path = os.path.join(workdir or os.getcwd(), path)
|
|
9256
|
+
if os.path.isfile(path):
|
|
9257
|
+
return os.path.normpath(path)
|
|
9258
|
+
return ""
|
|
9259
|
+
|
|
9260
|
+
|
|
9261
|
+
def _file_tail(path: str, max_chars: int = 4000) -> str:
|
|
9262
|
+
try:
|
|
9263
|
+
with open(path, "rb") as fh:
|
|
9264
|
+
fh.seek(0, 2)
|
|
9265
|
+
size = fh.tell()
|
|
9266
|
+
fh.seek(max(0, size - max_chars))
|
|
9267
|
+
text = fh.read().decode("utf-8", errors="replace")
|
|
9268
|
+
return text if size <= max_chars else text.split("\n", 1)[-1]
|
|
9269
|
+
except OSError:
|
|
9270
|
+
return ""
|
|
9271
|
+
|
|
9272
|
+
|
|
9062
9273
|
def _shell_kill(s: dict) -> None:
|
|
9063
9274
|
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
9064
9275
|
import time as _t
|
|
@@ -10418,9 +10629,16 @@ def run_agent_chat(cfg):
|
|
|
10418
10629
|
_ws_servers_line += _shells_context_line(_shells_load())
|
|
10419
10630
|
except Exception as _e: # noqa: BLE001
|
|
10420
10631
|
_log(f"shell context skip: {_e}")
|
|
10632
|
+
_ws_tests_line = ""
|
|
10633
|
+
if _WS_AVAILABLE and _WS_ACTIVE:
|
|
10634
|
+
try:
|
|
10635
|
+
_ws_tests_line = _test_commands_line(_WS_ACTIVE)
|
|
10636
|
+
except Exception as _e: # noqa: BLE001
|
|
10637
|
+
_log(f"test command scan skip: {_e}")
|
|
10421
10638
|
_ws_context_block = (
|
|
10422
10639
|
_ws_current_line
|
|
10423
10640
|
+ f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
|
|
10641
|
+
+ _ws_tests_line
|
|
10424
10642
|
+ _ws_servers_line
|
|
10425
10643
|
+ f" Current folder contents:\n"
|
|
10426
10644
|
+ "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
|
|
@@ -14565,8 +14783,11 @@ def run_agent_chat(cfg):
|
|
|
14565
14783
|
# Every simple command in the line, including those inside $( ), gets the
|
|
14566
14784
|
# checks an argv command gets — allowlist, privilege, denylist, deletes —
|
|
14567
14785
|
# plus the shell-only ones (redirect outside the project, `| sh`).
|
|
14786
|
+
_resolved = _shell_resolve_runner_vars(command, _scan)
|
|
14787
|
+
_risk_scan = _shell_scan(_resolved) if _resolved else _scan
|
|
14568
14788
|
_refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
|
|
14569
|
-
|
|
14789
|
+
_risk_scan if not _risk_scan.get("error") else _scan,
|
|
14790
|
+
_WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
|
|
14570
14791
|
if _refuse:
|
|
14571
14792
|
_last_obs["text"] = _refuse
|
|
14572
14793
|
return _refuse
|
|
@@ -14754,6 +14975,11 @@ def run_agent_chat(cfg):
|
|
|
14754
14975
|
# `git commit -m '…turn'\''s…'` lost its backslash to Python and bash
|
|
14755
14976
|
# stopped on the quote; the next step was spent guessing. A heredoc in a
|
|
14756
14977
|
# triple-quoted string has no quoting to get wrong.
|
|
14978
|
+
_hidden = _pipe_hidden_failure(_scan, out) if (rc == 0 and _use_shell) else ""
|
|
14979
|
+
if _hidden:
|
|
14980
|
+
result += _pipe_hidden_failure_note(_hidden)
|
|
14981
|
+
_emit({"type": "step", "text":
|
|
14982
|
+
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
14757
14983
|
if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
|
|
14758
14984
|
result += _HEREDOC_HINT
|
|
14759
14985
|
if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
|
|
@@ -14971,8 +15197,15 @@ def run_agent_chat(cfg):
|
|
|
14971
15197
|
if st["state"] == "exited":
|
|
14972
15198
|
head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
|
|
14973
15199
|
"the output and VERIFY.")
|
|
15200
|
+
tail_note = ""
|
|
15201
|
+
if not new.strip() and int(sh.get("read_offset") or 0) == 0:
|
|
15202
|
+
lf = _shell_log_file(str(sh.get("command") or ""), str(sh.get("workdir") or ""))
|
|
15203
|
+
lt = _file_tail(lf).strip() if lf else ""
|
|
15204
|
+
if lt:
|
|
15205
|
+
tail_note = (f"\nIt prints nothing itself; the end of the log it writes "
|
|
15206
|
+
f"({lf}):\n{lt}")
|
|
14974
15207
|
msg = head + "\n" + (f"New output:\n{new}" if new.strip()
|
|
14975
|
-
else "No new output since the last read.")
|
|
15208
|
+
else "No new output since the last read.") + tail_note
|
|
14976
15209
|
_emit({"type": "step", "text":
|
|
14977
15210
|
f"Shell {sh.get('id')} {_shell_status_text(st)} → {_trim_words(str(sh.get('command')))}"})
|
|
14978
15211
|
_last_obs["text"] = msg
|
package/gu-repl.mjs
CHANGED
|
@@ -139,7 +139,7 @@ import {
|
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
140
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
-
import { killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
142
|
+
import { commandLogFile, killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
143
143
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
144
144
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
145
145
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -9510,8 +9510,18 @@ function printShellDetails(rec) {
|
|
|
9510
9510
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
9511
9511
|
const st = shellState(rec);
|
|
9512
9512
|
shellsViewed.add(String(rec.id));
|
|
9513
|
+
// Nothing printed of its own (Unity -batchmode -logFile …)? Show the log its command writes.
|
|
9514
|
+
let output = logTail(rec.log);
|
|
9515
|
+
let outputFrom = "";
|
|
9516
|
+
if (!output.length) {
|
|
9517
|
+
const lf = commandLogFile(rec.command, rec.workdir);
|
|
9518
|
+
if (lf) {
|
|
9519
|
+
output = logTail(lf);
|
|
9520
|
+
outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
|
|
9521
|
+
}
|
|
9522
|
+
}
|
|
9513
9523
|
const rows = renderShellDetails({
|
|
9514
|
-
rec, st, output
|
|
9524
|
+
rec, st, output, outputFrom, width: width - 1, gutter: GUTTER,
|
|
9515
9525
|
style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
|
|
9516
9526
|
});
|
|
9517
9527
|
console.log("\n" + GUTTER + accent("─".repeat(Math.max(10, width - 1 - GUTTER.length))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.832",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/shell-details.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
|
|
19
19
|
export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
|
|
@@ -135,6 +135,38 @@ export function logTail(path, { lines = 15, maxBytes = 64 * 1024 } = {}) {
|
|
|
135
135
|
return out.slice(-lines);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
const unq = (w) => w.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
|
|
139
|
+
const WORD = String.raw`("[^"]*"|'[^']*'|[^\s;&|<>]+)`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The log file a command writes to, when the shell itself prints nothing (#362: Unity's
|
|
143
|
+
* `-logFile Logs/all.log` left Shell details on "No output available" for a 5-minute run).
|
|
144
|
+
* An explicit log flag first, then `tee`, then the last `>`/`>>` redirect — the same order as
|
|
145
|
+
* the agent's _shell_log_file. → an absolute path that exists, or "".
|
|
146
|
+
*/
|
|
147
|
+
export function commandLogFile(command, workdir = "") {
|
|
148
|
+
const cmd = String(command ?? "");
|
|
149
|
+
const last = (re) => {
|
|
150
|
+
let m, v = "";
|
|
151
|
+
for (const r = new RegExp(re, "gi"); (m = r.exec(cmd)); ) v = m[1] ?? m[2] ?? v;
|
|
152
|
+
return v;
|
|
153
|
+
};
|
|
154
|
+
const flag = last(String.raw`(?:^|\s)--?(?:log-?file|logfile|log-path|log)(?:=` + WORD + String.raw`|\s+` + WORD + ")");
|
|
155
|
+
const tee = last(String.raw`\btee\s+(?:-a\s+)?` + WORD);
|
|
156
|
+
const redir = last(String.raw`(?:^|[^0-9&>])(?:1|&)?>>?\s*(?!&)` + WORD);
|
|
157
|
+
for (const raw of [flag, tee, redir]) {
|
|
158
|
+
if (!raw) continue;
|
|
159
|
+
let p = unq(raw).replace(/\$\{?PWD\}?/g, workdir || "");
|
|
160
|
+
if (p === "/dev/null" || p === "-") continue;
|
|
161
|
+
if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
|
|
162
|
+
if (!isAbsolute(p)) p = join(workdir || process.cwd(), p);
|
|
163
|
+
try {
|
|
164
|
+
if (statSync(p).isFile()) return normalize(p);
|
|
165
|
+
} catch { /* not there (yet) */ }
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
138
170
|
/** Word-wrap `text` to `width`; at most `maxRows`, the last ending in "…" when cut. */
|
|
139
171
|
export function wrapClip(text, width, maxRows = 4) {
|
|
140
172
|
const w = Math.max(10, width);
|
|
@@ -166,7 +198,7 @@ const plain = (s) => s;
|
|
|
166
198
|
* The Shell details block, one string per row, no row wider than `width`.
|
|
167
199
|
* `style`: {title, label, running, ok, bad, dim} paint functions (identity in tests).
|
|
168
200
|
*/
|
|
169
|
-
export function renderShellDetails({ rec, st, output = [], width = 80, gutter = " ", style = {} }) {
|
|
201
|
+
export function renderShellDetails({ rec, st, output = [], outputFrom = "", width = 80, gutter = " ", style = {} }) {
|
|
170
202
|
const p = { title: plain, label: plain, running: plain, ok: plain, bad: plain, dim: plain, ...style };
|
|
171
203
|
const inner = Math.max(20, width - gutter.length);
|
|
172
204
|
const valW = inner - LABEL_W;
|
|
@@ -183,7 +215,8 @@ export function renderShellDetails({ rec, st, output = [], width = 80, gutter =
|
|
|
183
215
|
...cmd.slice(1).map((c) => gutter + pad + c),
|
|
184
216
|
];
|
|
185
217
|
if (rec?.workdir) rows.push(row("Folder:", p.dim(wrapClip(rec.workdir, valW, 1)[0])));
|
|
186
|
-
|
|
218
|
+
// From the log file the command writes, when the shell printed nothing itself — say which.
|
|
219
|
+
rows.push("", gutter + p.label("Output:") + (outputFrom ? p.dim(` (from ${wrapClip(outputFrom, Math.max(10, inner - 20), 1)[0]})`) : ""));
|
|
187
220
|
if (!output.length) rows.push(gutter + p.dim("No output available"));
|
|
188
221
|
else for (const l of output) rows.push(gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l));
|
|
189
222
|
return rows;
|