diff --git a/skills/kvb-org-operator/scripts/operator.py b/skills/kvb-org-operator/scripts/operator.py index c3bc231..027ac4d 100755 --- a/skills/kvb-org-operator/scripts/operator.py +++ b/skills/kvb-org-operator/scripts/operator.py @@ -6,6 +6,8 @@ Environment: by rasci.py; the rows are its tasks operator/. CLAUDE_CODE_SESSION_ID: the adding session, recorded as A; other runtimes pass --session. + NO_COLOR: when set and not empty, run prints the command uncolored + (https://no-color.org). PATH: searched by the binary check. SHELL: run executes each approved command with `$SHELL -ic`, so the operator's aliases and functions work; required by run. @@ -29,7 +31,8 @@ Args: set the passing ones done. run: walk the open rows that hold a directory and a command, in list order. Per row it prints directory, command (control characters made - visible), reason and check, then a preview of what the command + visible; bold cyan when stdout is a terminal and NO_COLOR is not + set), reason and check, then a preview of what the command changes, and asks [y]es / [s]kip / [d]iff / [q]uit; d shows the full diff. A repo whose git config names filter commands gets no preview. y runs the command in its directory, then sets the row done @@ -104,6 +107,8 @@ HEADER = "| # | Directory | Command | Why | Check |" # Trip-wire: agents name these repos, and git runs commands their config names (fsmonitor, hooks, pager, external diff, textconv, gpg, filters, lazy fetch) as the operator. GIT = ["git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "log.showSignature=false", "--no-pager", "--no-optional-locks"] DIFF = [*GIT, "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=all"] +HIGHLIGHT = "\x1b[1;36m" +RESET = "\x1b[0m" def fail(message: str) -> None: @@ -300,6 +305,12 @@ def visible(text: str, keep: str) -> str: return "".join(c if c.isprintable() or c in keep else c.encode("unicode_escape").decode() for c in text) +def highlighted(text: str) -> str: + if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): + return f"{HIGHLIGHT}{text}{RESET}" + return text + + def git_tree(directory: str) -> bool: try: return subprocess.run( @@ -450,7 +461,7 @@ def cmd_granted(rasci: ModuleType, args: argparse.Namespace) -> int: row = approved[0] print(f"\ngranted {visible(row['task'], '')}: the queue lets it run now; its tree may have changed while it waited") print(f" directory: {visible(row['directory'], '')}") - print(f" command: {visible(row['command'], '')}", flush=True) + print(f" command: {highlighted(visible(row['command'], ''))}", flush=True) try: answer = review(row, False, timed) except KeyboardInterrupt: @@ -482,7 +493,7 @@ def cmd_run(rasci: ModuleType, args: argparse.Namespace) -> int: check = "manual" if problem(row["check"]) else row["check"] print(f"\n[{number}/{len(steps)}] {visible(row['task'], '')}") print(f" directory: {visible(row['directory'], '')}") - print(f" command: {visible(row['command'], '')}") + print(f" command: {highlighted(visible(row['command'], ''))}") print(f" why: {visible(row['why'] or '-', '')}") print(f" check: {visible(check, '')}", flush=True) holdable = heavy_row(watch, cfg, row) diff --git a/tests/test_operator.py b/tests/test_operator.py index 6c098ae..804a0e9 100644 --- a/tests/test_operator.py +++ b/tests/test_operator.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import pty import shutil import subprocess import sys @@ -94,6 +95,28 @@ class TestOperator(unittest.TestCase): cwd=self.tmp.name, check=False, ) + def _tty(self, extra: dict[str, str]) -> str: + leader, follower = pty.openpty() + self.addCleanup(os.close, leader) + try: + result = subprocess.run( + [sys.executable, str(OPERATOR), "run"], input=b"q\n", stdout=follower, stderr=subprocess.PIPE, + env={**self.env, "SHELL": "/bin/sh", **extra}, cwd=self.tmp.name, check=False, + ) + finally: + os.close(follower) + self.assertEqual(result.returncode, 0, result.stderr) + chunks = [] + while True: + try: + chunk = os.read(leader, 65536) + except OSError: + break + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks).decode() + def _open(self) -> list[str]: return [row["task"] for row in json.loads("\n".join(self._out("list", "--json")))] @@ -231,6 +254,18 @@ class TestOperator(unittest.TestCase): self.assertNotIn("\x1b", out) self.assertEqual(self._open(), ["operator/second", "operator/third"]) + def test_run_colors_the_command_only_on_a_terminal_without_no_color(self): + self._add("go", self.base, "true", "runnable", "manual") + piped = self._feed("q\n").stdout + self.assertIn(" command: true\n", piped) + self.assertNotIn("\x1b", piped) + colored = self._tty({}) + self.assertIn(" command: \x1b[1;36mtrue\x1b[0m\r\n", colored) + self.assertEqual(colored.count("\x1b"), 2) + plain = self._tty({"NO_COLOR": "1"}) + self.assertIn(" command: true\r\n", plain) + self.assertNotIn("\x1b", plain) + def test_run_previews_pushed_commits_and_keeps_failed_rows_open(self): repo = self._repo("ahead", upstream=True) self._add("push", repo, "false", "branch ahead", f"pushed:{repo}")