@tiens.nguyen/gu-cli 1.0.831 → 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.
@@ -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"]
@@ -9164,6 +9221,55 @@ def _shell_or_server(shells: list, servers: list, key: str):
9164
9221
  return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
9165
9222
 
9166
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
+
9167
9273
  def _shell_kill(s: dict) -> None:
9168
9274
  """Stop a shell's whole process group and mark it killed (unless it already finished)."""
9169
9275
  import time as _t
@@ -14677,8 +14783,11 @@ def run_agent_chat(cfg):
14677
14783
  # Every simple command in the line, including those inside $( ), gets the
14678
14784
  # checks an argv command gets — allowlist, privilege, denylist, deletes —
14679
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
14680
14788
  _refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
14681
- _scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
14789
+ _risk_scan if not _risk_scan.get("error") else _scan,
14790
+ _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
14682
14791
  if _refuse:
14683
14792
  _last_obs["text"] = _refuse
14684
14793
  return _refuse
@@ -15088,8 +15197,15 @@ def run_agent_chat(cfg):
15088
15197
  if st["state"] == "exited":
15089
15198
  head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
15090
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}")
15091
15207
  msg = head + "\n" + (f"New output:\n{new}" if new.strip()
15092
- else "No new output since the last read.")
15208
+ else "No new output since the last read.") + tail_note
15093
15209
  _emit({"type": "step", "text":
15094
15210
  f"Shell {sh.get('id')} {_shell_status_text(st)} → {_trim_words(str(sh.get('command')))}"})
15095
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: logTail(rec.log), width: width - 1, gutter: GUTTER,
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.831",
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
- rows.push("", gutter + p.label("Output:"));
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;